+
);
diff --git a/src/components/domains/main/MainSearchBar.tsx b/src/components/domains/main/MainSearchBar.tsx
index f9d6412e..0e3d20e9 100644
--- a/src/components/domains/main/MainSearchBar.tsx
+++ b/src/components/domains/main/MainSearchBar.tsx
@@ -1,11 +1,162 @@
"use client";
import { SearchBar } from "@/components/commons/search/SearchBar";
+import { useRouter } from "next/navigation";
+import { useSearchBar } from "@/lib/hooks/useSearchBar";
+import { useEffect, useState, useRef } from "react";
+import useDebounce from "@/lib/hooks/useDebounce";
+import { searchApi } from "@/lib/utils/api/search.api";
+import { SearchResultsDropdown } from "@/components/commons/dropdown/SearchResultsDropdown";
+import useOnClickOutside from "@/lib/hooks/useOnClickOutside";
+import { useVisibilityStore } from "@/lib/stores/useVisibilityStore";
+import Image from "next/image";
+import ICONS from "@/lib/constants/icons";
+import { highlightText } from "@/lib/utils/highlightText";
+
+interface PerfumeSearchResult {
+ id: string;
+ nameKo: string | null;
+ nameEn: string;
+ brand: {
+ nameKo: string | null;
+ nameEn: string;
+ };
+ perfumeImage: {
+ imageUrl: string;
+ } | null;
+}
export const MainSearchBar = () => {
+ const router = useRouter();
+ const [searchResults, setSearchResults] = useState
([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const searchContainerRef = useRef(null);
+
+ const dropdownId = "mainSearchDropdown";
+ const { isOpen, open, close } = useVisibilityStore();
+ useOnClickOutside(searchContainerRef, () => close(dropdownId));
+
+ const { value, handleChange, handleSubmit } = useSearchBar({
+ onSearch: (searchTerm) => {
+ close(dropdownId);
+ if (searchTerm) {
+ router.push(`/perfumes?q=${encodeURIComponent(searchTerm)}`);
+ } else {
+ router.push("/perfumes");
+ }
+ },
+ });
+
+ const debouncedSearchQuery = useDebounce(value, 500);
+
+ // 디바운싱 중인지 확인
+ useEffect(() => {
+ if (value.trim() !== "" && value !== debouncedSearchQuery) {
+ setIsLoading(true);
+ }
+ }, [value, debouncedSearchQuery]);
+
+ useEffect(() => {
+ const fetchSearchResults = async () => {
+ if (debouncedSearchQuery.trim() === "") {
+ setSearchResults([]);
+ close(dropdownId);
+ setIsLoading(false);
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ const response = await searchApi.perfumes({
+ searchText: debouncedSearchQuery,
+ limit: 10,
+ });
+ if (response && response.success && "data" in response) {
+ setSearchResults(response.data.data);
+ open(dropdownId);
+ }
+ } catch (error) {
+ console.error("향수 검색 실패:", error);
+ setSearchResults([]);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchSearchResults();
+ }, [debouncedSearchQuery, close, open, dropdownId]);
+
+ const handleResultClick = (perfumeId: string) => {
+ close(dropdownId);
+ router.push(`/perfumes/${perfumeId}`);
+ };
+
return (
);
};
diff --git a/src/components/domains/main/mainBanner/MainBanner.tsx b/src/components/domains/main/mainBanner/MainBanner.tsx
index 23c21e68..18c5ff2d 100644
--- a/src/components/domains/main/mainBanner/MainBanner.tsx
+++ b/src/components/domains/main/mainBanner/MainBanner.tsx
@@ -1,11 +1,9 @@
-"use client";
-
import { MainBannerPerfumeList } from "./MainBannerPerfumeList";
-import { SkeletonBanner } from "@/components/domains/user/components/skeletons/SkeletonBanner";
-import { usePerfumeByTheme } from "@/lib/hooks/query/usePerfumeQuery";
+import { getPerfumesListByThemeService } from "@/lib/hono/services/perfume.service";
-export const MainBanner = () => {
- const { data = [], isLoading } = usePerfumeByTheme("FRESH");
+export const MainBanner = async () => {
+ const result = await getPerfumesListByThemeService("mostLike");
+ const data = result.success ? result.data : [];
return (
@@ -13,11 +11,7 @@ export const MainBanner = () => {
현재 가장 인기가 많은 향수 TOP 5
- {isLoading ? (
-
- ) : (
- data &&
- )}
+
);
diff --git a/src/components/domains/main/mainBanner/MainBannerPerfumeList.tsx b/src/components/domains/main/mainBanner/MainBannerPerfumeList.tsx
index b4f5391e..4974cdc5 100644
--- a/src/components/domains/main/mainBanner/MainBannerPerfumeList.tsx
+++ b/src/components/domains/main/mainBanner/MainBannerPerfumeList.tsx
@@ -1,5 +1,3 @@
-"use client";
-
import PerfumeCard from "@/components/commons/card/perfumeCard";
import Link from "next/link";
import { ApiPerfumeSimpleResponse } from "@/lib/hono/schemas/perfume.schema";
@@ -14,7 +12,7 @@ export const MainBannerPerfumeList = ({
return (
- {data.map((item) => (
+ {data.map((item, index) => (
))}
diff --git a/src/components/domains/main/mainContent/mainContentCommunity/MainContentCommunity.tsx b/src/components/domains/main/mainContent/mainContentCommunity/MainContentCommunity.tsx
index 0b91059a..1c03c1d1 100644
--- a/src/components/domains/main/mainContent/mainContentCommunity/MainContentCommunity.tsx
+++ b/src/components/domains/main/mainContent/mainContentCommunity/MainContentCommunity.tsx
@@ -1,12 +1,20 @@
import { MainContentCommunityList } from "./MainContentCommunityList";
+import { getPaginatedPostListService } from "@/lib/hono/services/community.service";
+
+export const MainContentCommunity = async () => {
+ const result = await getPaginatedPostListService({
+ sortBy: "popular",
+ limit: 8,
+ });
+
+ const initialData = result.success ? result.data.data : [];
-export const MainContentCommunity = () => {
return (
);
};
diff --git a/src/components/domains/main/mainContent/mainContentCommunity/MainContentCommunityList.tsx b/src/components/domains/main/mainContent/mainContentCommunity/MainContentCommunityList.tsx
index c344f6ad..a65c1064 100644
--- a/src/components/domains/main/mainContent/mainContentCommunity/MainContentCommunityList.tsx
+++ b/src/components/domains/main/mainContent/mainContentCommunity/MainContentCommunityList.tsx
@@ -4,16 +4,21 @@ import { BOARD_TYPES } from "@/lib/constants/communityBoard";
import ICONS from "@/lib/constants/icons";
import Image from "next/image";
import { useState } from "react";
-import { boardDataMap } from "@/lib/mocks/communityPosts";
import BoardTabBar from "@/components/commons/tabBar/BoardTabBar";
import Link from "next/link";
+import { useCommunityPosts } from "@/lib/hooks/query/useCommunityQuery";
+import { PostCategory } from "@prisma/client";
+import { Indicator } from "@/components/commons/loading/Indicator";
+import { BasePost } from "@/lib/hono/utils/prisma.utils";
interface IMainContentCommunityList {
size: "s" | "m";
+ initialData?: BasePost[];
}
export const MainContentCommunityList = ({
size,
+ initialData = [],
}: IMainContentCommunityList) => {
const boards = Object.entries(BOARD_TYPES).map(([key, value]) => ({
key,
@@ -21,9 +26,22 @@ export const MainContentCommunityList = ({
}));
const [selectedTab, setSelectedTab] = useState(boards[0].key);
+ const [hasChangedTab, setHasChangedTab] = useState(false);
+
+ const { data: apiData, isLoading } = useCommunityPosts(
+ {
+ category: selectedTab as PostCategory,
+ sortBy: "popular",
+ limit: 8,
+ },
+ hasChangedTab,
+ );
+
+ const posts = !hasChangedTab ? initialData : apiData?.data || [];
const handleTabClick = (key: string) => {
setSelectedTab(key);
+ setHasChangedTab(true);
};
return (
@@ -39,29 +57,44 @@ export const MainContentCommunityList = ({
handleTabClick={handleTabClick}
/>
-
- {boardDataMap[selectedTab].map((post) => (
-
-
- {post.title}
-
-
-
-
- {post.commentCount > 1000 ? "999+" : post.commentCount}
-
-
-
- ))}
+
+ {isLoading ? (
+
+
+
+ ) : posts.length === 0 ? (
+
+ 게시글이 없습니다.
+
+ ) : (
+ <>
+ {posts.map((post) => (
+
+ {/* title */}
+
+ {post.title}
+
+
+ {/* comment meta */}
+
+
+
+ {post.commentCount > 1000 ? "999+" : post.commentCount}
+
+
+
+ ))}
+ >
+ )}
);
diff --git a/src/components/domains/perfumes/hook/usePerfumeSearchState.ts b/src/components/domains/perfumes/hook/usePerfumeSearchState.ts
index 9a60ac02..3ea074dc 100644
--- a/src/components/domains/perfumes/hook/usePerfumeSearchState.ts
+++ b/src/components/domains/perfumes/hook/usePerfumeSearchState.ts
@@ -1,13 +1,25 @@
"use client";
-import { useState, useMemo, ChangeEvent, FormEvent } from "react";
+import { useState, useMemo, ChangeEvent, FormEvent, useEffect } from "react";
+import { useSearchParams } from "next/navigation";
import { ApiBrandSimpleResponse } from "@/lib/hono/schemas/brand.schema";
export const usePerfumeSearchState = (
brands: ApiBrandSimpleResponse[]
) => {
- const [inputValue, setInputValue] = useState("");
- const [searchKeyword, setSearchKeyword] = useState("");
+ const searchParams = useSearchParams();
+ const queryParam = searchParams.get("q");
+
+ const [inputValue, setInputValue] = useState(queryParam || "");
+ const [searchKeyword, setSearchKeyword] = useState(queryParam || "");
+
+ // URL의 query parameter가 변경되면 검색어 업데이트
+ useEffect(() => {
+ if (queryParam !== null) {
+ setInputValue(queryParam);
+ setSearchKeyword(queryParam);
+ }
+ }, [queryParam]);
// 검색어 제출 핸들러
const handleSubmit = (e?: FormEvent) => {
diff --git a/src/components/modal/LoginModalProvider.tsx b/src/components/modal/LoginModalProvider.tsx
index 31c20bad..0f4eac69 100644
--- a/src/components/modal/LoginModalProvider.tsx
+++ b/src/components/modal/LoginModalProvider.tsx
@@ -1,10 +1,10 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, Suspense } from "react";
+import { createPortal } from "react-dom";
import { useModalStore } from "@/lib/stores/useModalStore";
import { LoginModal } from "@/components/modal/LoginModal";
import { MODAL_KEYS } from "@/lib/stores/useModalStore";
-import { createPortal } from "react-dom";
export default function LoginModalProvider() {
const { loginModal, closeModal } = useModalStore();
@@ -20,7 +20,9 @@ export default function LoginModalProvider() {
if (!modalRoot) return null;
return createPortal(
-
closeModal(MODAL_KEYS.LOGIN)} />,
- modalRoot
+
+ closeModal(MODAL_KEYS.LOGIN)} />
+ ,
+ modalRoot,
);
}
diff --git a/src/lib/hooks/query/useCommunityQuery.ts b/src/lib/hooks/query/useCommunityQuery.ts
index 217654ee..7cd185aa 100644
--- a/src/lib/hooks/query/useCommunityQuery.ts
+++ b/src/lib/hooks/query/useCommunityQuery.ts
@@ -10,7 +10,7 @@ import { communityApi } from "../../utils/api/community.api";
import { queryKeys } from "../../utils/queryKeys";
// 커뮤니티 게시글 목록 조회
-export const useCommunityPosts = (params?: GetPostsQuery) => {
+export const useCommunityPosts = (params?: GetPostsQuery, enabled: boolean = true) => {
return useQuery({
queryKey: queryKeys.community.posts({
searchKeyword: params?.q || "",
@@ -20,6 +20,7 @@ export const useCommunityPosts = (params?: GetPostsQuery) => {
},
}),
queryFn: () => communityApi.list(params),
+ enabled,
select: (response) => {
if (!response || !response.success) return null;
return response.data;
diff --git a/src/lib/hooks/query/useUserQuery.ts b/src/lib/hooks/query/useUserQuery.ts
index 699f76f3..a237876c 100644
--- a/src/lib/hooks/query/useUserQuery.ts
+++ b/src/lib/hooks/query/useUserQuery.ts
@@ -1,4 +1,9 @@
-import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ useSuspenseQuery,
+} from "@tanstack/react-query";
import { meApi, userApi, USER_ID_REGEX } from "../../utils/api/users.api";
import { queryKeys } from "../../utils/queryKeys";
import type { ApiUpdateMyProfileRequest } from "@/lib/hono/schemas/me.schema";
@@ -7,7 +12,19 @@ import type { ApiUpdateMyProfileRequest } from "@/lib/hono/schemas/me.schema";
export const useUserProfile = (options?: { enabled?: boolean }) => {
return useQuery({
queryKey: queryKeys.user.profile("me"),
- queryFn: () => meApi.profile.get(),
+ queryFn: async () => {
+ try {
+ const response = await meApi.profile.get();
+ if (!response || !response.success) return null;
+ return response.data;
+ } catch (error) {
+ const errorStatus = (error as { status?: number })?.status;
+ if (errorStatus === 401 || errorStatus === 403) {
+ return null;
+ }
+ throw error;
+ }
+ },
enabled: options?.enabled ?? true,
retry: (failureCount, error) => {
// 401, 403 에러는 재시도하지 않음 (인증 실패)
@@ -17,10 +34,9 @@ export const useUserProfile = (options?: { enabled?: boolean }) => {
}
return failureCount < 3;
},
- select: (response) => {
- if (!response || !response.success) return null;
- return response.data;
- },
+ refetchOnWindowFocus: true, // OAuth 콜백 후 윈도우 포커스 시 재검증
+ refetchOnMount: true, // 페이지 이동 후 마운트 시 재검증
+ staleTime: 1000, // 1초 후 stale 처리 (OAuth 콜백 빠른 감지)
});
};
@@ -188,7 +204,7 @@ export const useUploadProfileImage = () => {
queryClient.setQueryData(queryKeys.user.profile("me"), response);
queryClient.setQueryData(
queryKeys.user.profile(response.data.id),
- response
+ response,
);
},
onError: (error) => {
diff --git a/src/lib/hooks/useSearchBar.ts b/src/lib/hooks/useSearchBar.ts
new file mode 100644
index 00000000..7bf8860d
--- /dev/null
+++ b/src/lib/hooks/useSearchBar.ts
@@ -0,0 +1,36 @@
+"use client";
+
+import { useState, ChangeEvent, FormEvent } from "react";
+
+interface UseSearchBarOptions {
+ initialValue?: string;
+ onSearch?: (searchTerm: string) => void;
+}
+
+export function useSearchBar({ initialValue = "", onSearch }: UseSearchBarOptions = {}) {
+ const [value, setValue] = useState(initialValue);
+
+ const handleChange = (e: ChangeEvent) => {
+ setValue(e.target.value);
+ };
+
+ const handleSubmit = (e?: FormEvent) => {
+ if (e) e.preventDefault();
+ const trimmedValue = value.trim();
+ if (onSearch) {
+ onSearch(trimmedValue);
+ }
+ };
+
+ const reset = () => {
+ setValue("");
+ };
+
+ return {
+ value,
+ setValue,
+ handleChange,
+ handleSubmit,
+ reset,
+ };
+}
diff --git a/src/lib/utils/highlightText.tsx b/src/lib/utils/highlightText.tsx
new file mode 100644
index 00000000..c0630c53
--- /dev/null
+++ b/src/lib/utils/highlightText.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+
+/**
+ * 텍스트에서 검색어와 일치하는 부분을 하이라이트합니다.
+ * @param text - 원본 텍스트
+ * @param query - 검색어
+ * @returns 하이라이트된 JSX 요소
+ */
+export function highlightText(text: string, query: string): React.ReactNode {
+ if (!query.trim()) {
+ return text;
+ }
+
+ const regex = new RegExp(
+ `(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`,
+ "gi",
+ );
+ const parts = text.split(regex);
+
+ return parts.map((part, index) => {
+ if (part.toLowerCase() === query.toLowerCase()) {
+ return (
+
+ {part}
+
+ );
+ }
+ return {part};
+ });
+}