Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Providers from "@/components/commons/Provider/TanstackProvider";
import GlobalStateSync from "@/components/commons/Provider/GlobalStateSync";
import RecentSyncManager from "@/components/commons/Provider/RecentSyncManager";
import KakaoScript from "@/lib/script/KakaoScript";
import { auth } from "@/auth";

const pretendard = localFont({
src: "../../public/fonts/PretendardVariable.woff2",
Expand All @@ -15,19 +16,22 @@ const pretendard = localFont({
variable: "--font-pretendard",
});

export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await auth();
const isAuthenticated = !!session?.user;

return (
<html lang="kr" className="h-dvh overflow-y-scroll">
<body className={`${pretendard.variable} font-pretendard h-full`}>
<Providers>
<RecentSyncManager />
<NavBarWrapper />
<BodyWrapper>
<GlobalStateSync />
<GlobalStateSync isAuthenticated={isAuthenticated} />
{children}
</BodyWrapper>
<LoginModalProvider />
Expand Down
5 changes: 4 additions & 1 deletion src/app/perfumes/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
import { Suspense } from "react";
import getQueryClient from "@/lib/utils/getQueryClient";
import { brandApi } from "@/lib/utils/api/brands.api";
import { perfumeApi } from "@/lib/utils/api/perfumes.api";
Expand All @@ -24,7 +25,9 @@ export default async function Page() {

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PageClient />
<Suspense>
<PageClient />
</Suspense>
</HydrationBoundary>
);
}
24 changes: 20 additions & 4 deletions src/components/commons/Provider/GlobalStateSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,37 @@ import { useUserProfile } from "@/lib/hooks/query/useUserQuery";
import { useUserStore } from "@/lib/stores/useUserStore";
import { pointApi } from "@/lib/utils/api/points.api";

interface GlobalStateSyncProps {
isAuthenticated: boolean;
}

//전역으로 사용하는 상태 동기화
export default function GlobalStateSync() {
const { data: userProfile, isFetched } = useUserProfile();
export default function GlobalStateSync({ isAuthenticated }: GlobalStateSyncProps) {
const { setUser, reset } = useUserStore();

// 서버에서 인증된 경우에만 프로필 조회
const { data: userProfile, isFetched } = useUserProfile({
enabled: isAuthenticated,
});

// 인증되지 않은 경우 즉시 reset
useEffect(() => {
if (!isAuthenticated) {
reset();
}
}, [isAuthenticated, reset]);

// 사용자 프로필 동기화
useEffect(() => {
if (isFetched) {
// 인증된 경우에만 프로필 동기화
if (isAuthenticated && isFetched) {
if (userProfile) {
setUser(userProfile);
} else {
reset();
}
}
}, [isFetched, userProfile, setUser, reset]);
}, [isAuthenticated, isFetched, userProfile, setUser, reset]);

// 일일 로그인 체크
useEffect(() => {
Expand Down
6 changes: 4 additions & 2 deletions src/components/commons/card/perfumeCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default function PerfumeCard({
onClick,
onClose,
className = "",
priority = false,
}: PerfumeCardProps) {
return (
<article
Expand All @@ -37,13 +38,14 @@ export default function PerfumeCard({
alt={perfumeName || "Perfume Image"}
fill
sizes={`
(max-width: 768px) 100vw,
(max-width: 1200px) 50vw,
(max-width: 768px) 100vw,
(max-width: 1200px) 50vw,
33vw
`}
placeholder="blur"
blurDataURL="/images/BlurShimmer.svg"
className="object-contain"
priority={priority}
/>
</figure>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface PerfumeCardProps {
onClick?: () => void;
onClose?: () => void;
className?: string;
priority?: boolean;
}

export type { PerfumeCardType, PerfumeCardProps };
10 changes: 4 additions & 6 deletions src/components/commons/dropdown/SearchResultsDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,14 @@ export function SearchResultsDropdown<T extends { id: string }>({
children,
}: SearchResultsDropdownProps<T>) {
return (
<div className="absolute top-full left-0 right-0 -mt-5 bg-white border border-gray-200 rounded-lg shadow-s z-20 max-h-60 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-gray-500">검색 중...</div>
) : results.length > 0 ? (
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-gray-200 rounded-lg shadow-s z-20 max-h-60 overflow-y-auto">
{results.length > 0 ? (
<ul>{results.map((item) => children(item))}</ul>
) : (
) : !isLoading ? (
<div className="p-4 text-center text-gray-500">
검색 결과가 없습니다.
</div>
)}
) : null}
</div>
);
}
3 changes: 3 additions & 0 deletions src/components/commons/perfumeList/search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface SearchHeaderProps {
notes: ApiPerfumeNoteResponse[];
accords: ApiPerfumeAccordResponse[];
isSearch?: boolean;
searchBarMaxWidth?: string;
}

export function SearchHeader({
Expand All @@ -25,6 +26,7 @@ export function SearchHeader({
notes,
accords,
isSearch = true,
searchBarMaxWidth = "840px",
}: SearchHeaderProps) {
return (
<header className="w-full px-4">
Expand All @@ -34,6 +36,7 @@ export function SearchHeader({
value={inputValue}
onChange={onChange}
onClick={onSubmit}
maxWidth={searchBarMaxWidth}
/>
)}
<nav className="w-full mt-7">
Expand Down
35 changes: 25 additions & 10 deletions src/components/commons/search/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export interface SearchBarProps {
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
buttonType?: "submit" | "button";
maxWidth?: string;
className?: string;
isLoading?: boolean;
[key: string]: unknown;
}

Expand All @@ -22,16 +25,22 @@ export function SearchBar({
onClick,
placeholder,
buttonType = "button",
maxWidth = "840px",
className = "",
isLoading = false,
...rest
}: SearchBarProps) {
const baseStyle = `
w-full max-w-[840px] max-h-10 tablet:h-12 tablet:max-h-[48px]
w-full max-h-10 tablet:h-12 tablet:max-h-[48px]
rounded-lg px-5 py-3 border flex items-center
focus:outline-none focus:border-primary-300 text-label-1 tablet:text-body-1
`;

return (
<div className="relative w-full max-w-[840px] flex items-center">
<div
className={`relative w-full flex items-center ${className}`}
style={{ maxWidth }}
>
<input
className={`${baseStyle} pr-10`}
placeholder={
Expand All @@ -43,14 +52,20 @@ export function SearchBar({
onChange={onChange}
{...rest}
/>
<button onClick={onClick} type={buttonType}>
<Image
src={ICONS.Search.src}
alt="Search"
width={20}
height={20}
className="absolute right-4 top-1/2 transform -translate-y-1/2"
/>
<button onClick={onClick} type={buttonType} disabled={isLoading}>
{isLoading ? (
<div className="absolute right-4 top-1/2 transform -translate-y-1/2">
<div className="w-5 h-5 border-2 border-primary-300 border-t-transparent rounded-full animate-spin" />
</div>
) : (
<Image
src={ICONS.Search.src}
alt="Search"
width={20}
height={20}
className="absolute right-4 top-1/2 transform -translate-y-1/2"
/>
)}
</button>
</div>
);
Expand Down
153 changes: 152 additions & 1 deletion src/components/domains/main/MainSearchBar.tsx
Original file line number Diff line number Diff line change
@@ -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<PerfumeSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const searchContainerRef = useRef<HTMLDivElement>(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 (
<div className="flex justify-center items-center tablet:py-10 tablet:px-0 p-5">
<SearchBar />
<form onSubmit={handleSubmit} className="w-full max-w-[840px]">
<div ref={searchContainerRef} className="relative w-full">
<SearchBar
value={value}
onChange={handleChange}
onClick={handleSubmit}
buttonType="submit"
maxWidth="100%"
isLoading={isLoading}
onFocus={() => {
if (value && searchResults.length > 0) open(dropdownId);
}}
/>

{isOpen(dropdownId) && (
<SearchResultsDropdown
isLoading={isLoading}
results={searchResults}
>
{(perfume) => (
<li
key={perfume.id}
onClick={() => handleResultClick(perfume.id)}
className="flex items-center gap-3 p-3 hover:bg-primary-500 cursor-pointer transition-colors"
>
<div className="w-12 h-12 relative flex-shrink-0 bg-gray-200 rounded overflow-hidden">
{perfume.perfumeImage?.imageUrl ? (
<Image
src={perfume.perfumeImage.imageUrl}
alt={perfume.nameKo || perfume.nameEn}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Image
src={ICONS.Logo.src}
alt="No image"
width={24}
height={24}
/>
</div>
)}
</div>
<div className="flex flex-col">
<span className="text-body-2 font-medium text-black-100">
{highlightText(
perfume.nameKo || perfume.nameEn,
debouncedSearchQuery,
)}
</span>
<span className="text-label-2 text-gray-500">
{highlightText(
perfume.brand.nameKo || perfume.brand.nameEn,
debouncedSearchQuery,
)}
</span>
</div>
</li>
)}
</SearchResultsDropdown>
)}
</div>
</form>
</div>
);
};
Loading