diff --git a/src/app/assets/icon.svg b/public/logo.svg
similarity index 100%
rename from src/app/assets/icon.svg
rename to public/logo.svg
diff --git a/src/app/index.html b/src/app/index.html
index 94a79be..6a0cda3 100644
--- a/src/app/index.html
+++ b/src/app/index.html
@@ -2,7 +2,7 @@
-
+
-
+
diff --git a/src/app/public/logo.svg b/src/app/public/logo.svg
new file mode 100644
index 0000000..54452bf
--- /dev/null
+++ b/src/app/public/logo.svg
@@ -0,0 +1,9 @@
+
diff --git a/src/app/routes/App.tsx b/src/app/routes/App.tsx
index c27472f..a96ed81 100644
--- a/src/app/routes/App.tsx
+++ b/src/app/routes/App.tsx
@@ -7,7 +7,8 @@ import MainLayout from "@app/routes/MainLayout";
import PrivateRoute from "@app/routes/PrivateRoute";
import { useAuthObserver } from "@shared/hooks/useAuthObserver";
-import LoadingSpinner from "@shared/ui/loading-spinner/LoadingSpinner";
+import { useLoadingCursor } from "@shared/hooks/useLoadingCursor";
+import PageTransitionLoader from "@shared/ui/loading-spinner/PageTransitionLoader";
const HomePage = lazy(() => import("@pages/home/ui/HomePage"));
const NotFoundPage = lazy(() => import("@pages/not-found/ui/NotFoundPage"));
@@ -26,47 +27,55 @@ const ProjectListPage = lazy(
() => import("@pages/project-list/ui/ProjectListPage")
);
+function AppContent(): JSX.Element {
+ useLoadingCursor();
+
+ return (
+ }>
+
+ {/* 헤더 없는 레이아웃 (로그인/회원가입 전용) */}
+ }>
+ } />
+ } />
+
+
+ {/* 헤더 포함 레이아웃 (메인 페이지) */}
+ }>
+ {/* 공개 페이지 */}
+ } />
+ } />
+ } />
+ } />
+
+ {/* 비공개 페이지 */}
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ );
+}
+
function App(): JSX.Element {
useAuthObserver();
return (
- }>
-
- {/* 헤더 없는 레이아웃 (로그인/회원가입 전용) */}
- }>
- } />
- } />
-
-
- {/* 헤더 포함 레이아웃 (메인 페이지) */}
- }>
- {/* 공개 페이지 */}
- } />
- } />
- } />
- } />
-
- {/* 비공개 페이지 */}
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
+
);
}
diff --git a/src/app/styles/global.css b/src/app/styles/global.css
index 481083a..ef8f4e1 100644
--- a/src/app/styles/global.css
+++ b/src/app/styles/global.css
@@ -59,3 +59,11 @@ body {
*::after {
box-sizing: inherit;
}
+
+body.page-loading {
+ cursor: wait;
+}
+
+body.page-loading * {
+ cursor: wait !important;
+}
diff --git a/src/entities/projects/ui/projects-card/ProjectCard.tsx b/src/entities/projects/ui/projects-card/ProjectCard.tsx
index 250a97f..9a49b19 100644
--- a/src/entities/projects/ui/projects-card/ProjectCard.tsx
+++ b/src/entities/projects/ui/projects-card/ProjectCard.tsx
@@ -1,6 +1,3 @@
-import AccessTimeIcon from "@mui/icons-material/AccessTime";
-import LocationPinIcon from "@mui/icons-material/LocationPin";
-import PeopleAltIcon from "@mui/icons-material/PeopleAlt";
import {
Button,
Card,
@@ -18,30 +15,38 @@ import type { JSX } from "react";
import { memo } from "react";
import { Link } from "react-router-dom";
-import { type ProjectListRes } from "@shared/types/project";
+import { RecruitmentStatus, type ProjectListRes } from "@shared/types/project";
import DragScrollContainer from "@shared/ui/DragScrollContainer";
+import {
+ AccessTimeIcon,
+ LocationPinIcon,
+ PeopleAltIcon,
+} from "@shared/ui/icons/CommonIcons";
import UserProfileAvatar from "@shared/ui/user/UserProfileAvatar";
import UserProfileWithNamePosition from "@shared/ui/user/UserProfileWithNamePosition";
interface ProjectCardProps {
project: ProjectListRes;
simple?: boolean;
- sx?: any;
}
const ProjectCard = ({
project,
simple = false,
- sx,
}: ProjectCardProps): JSX.Element => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.up("sm"));
+ const isRecruiting = project.status === RecruitmentStatus.recruiting;
return (
-
+
-
+
@@ -59,7 +64,7 @@ const ProjectCard = ({
>
)}
-
+
{isMobile ? (
)}
-
+
{!simple && (
{project.techStack.map((stack, index) => (
@@ -125,7 +130,9 @@ const ProjectCard = ({
export default memo(ProjectCard);
-const StyledCard = styled(Card)(({ theme }) => ({
+const StyledCard = styled(Card, {
+ shouldForwardProp: (prop) => prop !== "simple",
+})<{ simple?: boolean }>(({ theme, simple }) => ({
height: "100%",
flex: 1,
width: "100%",
@@ -134,6 +141,7 @@ const StyledCard = styled(Card)(({ theme }) => ({
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
cursor: "pointer",
border: `1px solid ${theme.palette.divider}`,
+ ...(simple && { minHeight: 260 }),
"&:hover": {
transform: "translateY(-0.4rem)",
@@ -172,15 +180,9 @@ const ProjectHeader = styled(Box)(() => ({
alignItems: "center",
}));
-const StatusChip = styled(Chip)(({ theme }) => ({
+const StatusChip = styled(Chip)(() => ({
fontWeight: 600,
letterSpacing: "0.025em",
- backgroundColor: theme.palette.primary.main,
- color: theme.palette.primary.contrastText,
-
- "&:hover": {
- backgroundColor: theme.palette.primary.dark,
- },
}));
const ContentSection = styled(Box)(({ theme }) => ({
@@ -208,6 +210,12 @@ const SimpleInfo = styled(Typography)(() => ({
WebkitBoxOrient: "vertical",
}));
+const UserProfileContainer = styled(Stack)(({ theme }) => ({
+ flexDirection: "row",
+ gap: theme.spacing(1),
+ alignItems: "flex-start",
+}));
+
const TechChip = styled(Chip)(({ theme }) => ({
backgroundColor: theme.palette.background.default,
border: `1px solid ${theme.palette.divider}`,
diff --git a/src/entities/search/api/getFilteredProjectLists.ts b/src/entities/search/api/getFilteredProjectLists.ts
deleted file mode 100644
index d5e4130..0000000
--- a/src/entities/search/api/getFilteredProjectLists.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import { getDocs, getCountFromServer } from "firebase/firestore";
-import type { QueryDocumentSnapshot, DocumentData } from "firebase/firestore";
-
-import { SearchFilterBuilder } from "@entities/search/model/searchFilterBuilder";
-import type { ProjectSearchFilterOption } from "@entities/search/types";
-
-import type { ProjectListRes } from "@shared/types/project";
-
-interface GetFilteredProjectListsOptions {
- cursor?: QueryDocumentSnapshot | null;
- pageSize?: number;
-}
-
-interface ProjectListWithPagination {
- projects: ProjectListRes[];
- lastVisible: QueryDocumentSnapshot | null;
- hasMore: boolean;
- totalCount?: number;
-}
-
-export const getFilteredProjectCount = async (
- collectionName: string,
- filter: ProjectSearchFilterOption
-): Promise => {
- const queryBuilder = new SearchFilterBuilder(collectionName)
- .setTitle(filter.title || undefined)
- .setCategory(filter.category === "all" ? undefined : filter.category)
- .setStatus(filter.status === "all" ? undefined : filter.status)
- .setWorkflow(filter.workflow === "all" ? undefined : filter.workflow);
-
- const query = queryBuilder.build();
-
- const snapshot = await getCountFromServer(query);
-
- if (filter.position && filter.position !== "all") {
- const allDocs = await getDocs(query);
-
- const filteredProjects = allDocs.docs.filter((doc) => {
- const data = doc.data() as ProjectListRes;
- return data.positions.some(
- (position) => position.position === filter.position
- );
- });
-
- return filteredProjects.length;
- }
-
- return snapshot.data().count;
-};
-
-export const getFilteredProjectsByPage = async (
- collectionName: string,
- filter: ProjectSearchFilterOption,
- page: number = 1,
- pageSize: number = 6
-): Promise => {
- const queryBuilder = new SearchFilterBuilder(collectionName)
- .setTitle(filter.title || undefined)
- .setCategory(filter.category === "all" ? undefined : filter.category)
- .setStatus(filter.status === "all" ? undefined : filter.status)
- .setWorkflow(filter.workflow === "all" ? undefined : filter.workflow)
- .setSortBy(filter.sortBy || "latest");
-
- if (filter.position && filter.position !== "all") {
- queryBuilder.addLimit(pageSize * 5);
- } else {
- const offset = (page - 1) * pageSize;
- queryBuilder.addLimit(offset + pageSize * 2);
- }
-
- const query = queryBuilder.build();
-
- const snapshot = await getDocs(query);
-
- let projects = snapshot.docs.map((doc) => ({
- id: doc.id,
- ...doc.data(),
- })) as ProjectListRes[];
-
- if (filter.position && filter.position !== "all") {
- projects = projects.filter((project) =>
- project.positions.some(
- (position) => position.position === filter.position
- )
- );
- }
-
- const offset = (page - 1) * pageSize;
- const startIndex = offset;
- const endIndex = startIndex + pageSize;
-
- const result = projects.slice(startIndex, endIndex);
-
- return result;
-};
-
-const getFilteredProjectLists = async (
- collectionName: string,
- filter: ProjectSearchFilterOption,
- options: GetFilteredProjectListsOptions = {}
-): Promise => {
- const { cursor, pageSize = 6 } = options;
-
- let queryBuilder = new SearchFilterBuilder(collectionName)
- .setTitle(filter.title || undefined)
- .setCategory(filter.category === "all" ? undefined : filter.category)
- .setStatus(filter.status === "all" ? undefined : filter.status)
- .setWorkflow(filter.workflow === "all" ? undefined : filter.workflow)
- .setSortBy(filter.sortBy || "latest");
-
- if (cursor) {
- queryBuilder.addStartAfter(cursor);
- }
-
- queryBuilder.addLimit(pageSize + 1);
-
- const query = queryBuilder.build();
-
- const snapshot = await getDocs(query);
- let projects = snapshot.docs.map((doc) => ({
- id: doc.id,
- ...doc.data(),
- })) as ProjectListRes[];
-
- if (filter.position && filter.position !== "all") {
- projects = projects.filter((project) =>
- project.positions.some(
- (position) => position.position === filter.position
- )
- );
- }
-
- const hasMore = projects.length > pageSize;
- if (hasMore) {
- projects = projects.slice(0, pageSize);
- }
-
- const lastVisible =
- projects.length > 0 ? snapshot.docs[projects.length - 1] : null;
-
- return {
- projects,
- lastVisible,
- hasMore,
- };
-};
-
-export const getFilteredProjectListsSimple = async (
- collectionName: string,
- filter: ProjectSearchFilterOption
-): Promise => {
- const result = await getFilteredProjectLists(collectionName, filter);
- return result.projects;
-};
-
-export default getFilteredProjectLists;
diff --git a/src/entities/search/api/projectSearchApi.ts b/src/entities/search/api/projectSearchApi.ts
new file mode 100644
index 0000000..f4e3a59
--- /dev/null
+++ b/src/entities/search/api/projectSearchApi.ts
@@ -0,0 +1,134 @@
+import { getDocs, getCountFromServer } from "firebase/firestore";
+
+import { SearchQueryBuilder } from "@entities/search/model/searchQueryBuilder";
+
+import type { ProjectListRes } from "@shared/types/project";
+import type { ProjectSearchFilterOption } from "@shared/types/search";
+
+const DEFAULT_PAGE_SIZE = 6;
+const MEMORY_FETCH_MULTIPLIER = 10;
+const PREFETCH_BUFFER_SIZE = 2;
+
+const shouldApplyFilter = (value: T | "all" | undefined): value is T => {
+ return value !== undefined && value !== "all";
+};
+
+const getFilteredValue = (value: T | "all" | undefined): T | undefined => {
+ return shouldApplyFilter(value) ? value : undefined;
+};
+
+const createBaseQuery = (
+ collectionName: string,
+ filter: ProjectSearchFilterOption
+): SearchQueryBuilder => {
+ return new SearchQueryBuilder(collectionName)
+ .setCategory(getFilteredValue(filter.category))
+ .setStatus(getFilteredValue(filter.status))
+ .setWorkflow(getFilteredValue(filter.workflow));
+};
+
+const needsInMemoryFiltering = (filter: ProjectSearchFilterOption): boolean => {
+ const hasTitle = filter.title && filter.title.trim();
+ const hasPosition = filter.position && filter.position !== "all";
+ return !!(hasTitle || hasPosition);
+};
+
+const filterByTitle = (
+ projects: ProjectListRes[],
+ searchTitle: string
+): ProjectListRes[] => {
+ const normalizedSearchTitle = searchTitle.toLowerCase().trim();
+ return projects.filter((project) =>
+ project.title.toLowerCase().includes(normalizedSearchTitle)
+ );
+};
+
+const filterByPosition = (
+ projects: ProjectListRes[],
+ position: string
+): ProjectListRes[] => {
+ return projects.filter((project) =>
+ project.positions.some((pos) => pos.position === position)
+ );
+};
+
+const applyInMemoryFilters = (
+ projects: ProjectListRes[],
+ filter: ProjectSearchFilterOption
+): ProjectListRes[] => {
+ let filteredProjects = projects;
+
+ if (filter.title && filter.title.trim()) {
+ filteredProjects = filterByTitle(filteredProjects, filter.title);
+ }
+
+ if (filter.position && filter.position !== "all") {
+ filteredProjects = filterByPosition(filteredProjects, filter.position);
+ }
+
+ return filteredProjects;
+};
+
+const fetchAndTransformProjects = async (
+ queryBuilder: SearchQueryBuilder
+): Promise => {
+ const query = queryBuilder.build();
+ const snapshot = await getDocs(query);
+
+ return snapshot.docs.map((doc) => ({
+ id: doc.id,
+ ...doc.data(),
+ })) as ProjectListRes[];
+};
+
+const applyPagination = (
+ projects: ProjectListRes[],
+ page: number,
+ pageSize: number
+): ProjectListRes[] => {
+ const offset = (page - 1) * pageSize;
+ const startIndex = offset;
+ const endIndex = startIndex + pageSize;
+
+ return projects.slice(startIndex, endIndex);
+};
+
+export const getProjectsCount = async (
+ collectionName: string,
+ filter: ProjectSearchFilterOption
+): Promise => {
+ const queryBuilder = createBaseQuery(collectionName, filter);
+
+ if (needsInMemoryFiltering(filter)) {
+ const projects = await fetchAndTransformProjects(queryBuilder);
+ const filteredProjects = applyInMemoryFilters(projects, filter);
+ return filteredProjects.length;
+ }
+
+ const query = queryBuilder.build();
+ const snapshot = await getCountFromServer(query);
+ return snapshot.data().count;
+};
+
+export const getProjectsByPage = async (
+ collectionName: string,
+ filter: ProjectSearchFilterOption,
+ page: number = 1,
+ pageSize: number = DEFAULT_PAGE_SIZE
+): Promise => {
+ const queryBuilder = createBaseQuery(collectionName, filter).setSortBy(
+ filter.sortBy || "latest"
+ );
+
+ if (needsInMemoryFiltering(filter)) {
+ queryBuilder.addLimit(pageSize * MEMORY_FETCH_MULTIPLIER);
+ } else {
+ const offset = (page - 1) * pageSize;
+ queryBuilder.addLimit(offset + pageSize * PREFETCH_BUFFER_SIZE);
+ }
+
+ const projects = await fetchAndTransformProjects(queryBuilder);
+ const filteredProjects = applyInMemoryFilters(projects, filter);
+
+ return applyPagination(filteredProjects, page, pageSize);
+};
diff --git a/src/entities/search/hooks/useFilteredProjects.ts b/src/entities/search/hooks/useFilteredProjects.ts
index 02f6249..d9d63cc 100644
--- a/src/entities/search/hooks/useFilteredProjects.ts
+++ b/src/entities/search/hooks/useFilteredProjects.ts
@@ -1,12 +1,11 @@
-import { useState } from "react";
-
-import type { ProjectSearchFilterOption, SortBy } from "@entities/search/types";
+import { useState, useMemo, useCallback } from "react";
import {
RecruitmentStatus,
type ProjectCategory,
type Workflow,
} from "@shared/types/project";
+import type { ProjectSearchFilterOption, SortBy } from "@shared/types/search";
import type { UserRole } from "@shared/types/user";
interface UseFilteredProjects {
@@ -25,7 +24,7 @@ interface UseFilteredProjects {
updateSortBy: (newSortBy: SortBy | "latest") => void;
resetFilters: () => void;
getActiveFiltersCount: () => number;
- getCleanFilter: () => ProjectSearchFilterOption;
+ getFilterStatus: () => ProjectSearchFilterOption;
}
const useFilteredProjects = (): UseFilteredProjects => {
@@ -79,7 +78,7 @@ const useFilteredProjects = (): UseFilteredProjects => {
return count;
};
- const getCleanFilter = (): ProjectSearchFilterOption => {
+ const getFilterStatus = useCallback((): ProjectSearchFilterOption => {
const cleanFilter: ProjectSearchFilterOption = {
title: title || "",
category: category === "all" ? undefined : category,
@@ -92,16 +91,19 @@ const useFilteredProjects = (): UseFilteredProjects => {
return Object.fromEntries(
Object.entries(cleanFilter).filter(([_, value]) => value !== undefined)
) as ProjectSearchFilterOption;
- };
-
- const filterState = {
- title,
- category,
- position,
- status,
- workflow,
- sortBy,
- };
+ }, [title, category, position, status, workflow, sortBy]);
+
+ const filterState = useMemo(
+ () => ({
+ title,
+ category,
+ position,
+ status,
+ workflow,
+ sortBy,
+ }),
+ [title, category, position, status, workflow, sortBy]
+ );
return {
filterState,
@@ -119,7 +121,7 @@ const useFilteredProjects = (): UseFilteredProjects => {
updateSortBy,
resetFilters,
getActiveFiltersCount,
- getCleanFilter,
+ getFilterStatus,
};
};
diff --git a/src/entities/search/hooks/useProjectListPage.ts b/src/entities/search/hooks/useProjectSearch.ts
similarity index 63%
rename from src/entities/search/hooks/useProjectListPage.ts
rename to src/entities/search/hooks/useProjectSearch.ts
index 8f511ea..c13c788 100644
--- a/src/entities/search/hooks/useProjectListPage.ts
+++ b/src/entities/search/hooks/useProjectSearch.ts
@@ -1,17 +1,17 @@
-import { useState } from "react";
+import { useState, useCallback } from "react";
import {
- useGetFilteredProjectsByPage,
- useGetFilteredProjectsCount,
-} from "@entities/search/queries/useGetFilteredProjectLists";
-import type { ProjectSearchFilterOption } from "@entities/search/types";
+ useProjectsByPage,
+ useProjectsCount,
+} from "@entities/search/queries/useProjectSearchQueries";
import { usePaginationWithState } from "@shared/hooks/usePagination";
import type { ProjectListRes } from "@shared/types/project";
+import type { ProjectSearchFilterOption } from "@shared/types/search";
const ITEMS_PER_PAGE = 6;
-interface UseProjectListPageReturn {
+interface UseProjectSearchReturn {
projects: ProjectListRes[];
totalCount: number;
currentFilter: ProjectSearchFilterOption;
@@ -26,16 +26,22 @@ interface UseProjectListPageReturn {
handlePageChange: (page: number) => void;
}
-const useProjectListPage = (): UseProjectListPageReturn => {
+const useProjectSearch = (): UseProjectSearchReturn => {
const [currentFilter, setCurrentFilter] = useState(
- {}
+ {
+ category: "all",
+ status: "all",
+ workflow: "all",
+ position: "all",
+ sortBy: "latest",
+ }
);
const {
data: totalCount = 0,
isLoading: isCountLoading,
isError: isCountError,
- } = useGetFilteredProjectsCount(currentFilter);
+ } = useProjectsCount(currentFilter);
const { currentPage, totalPages, setPage, goToReset } =
usePaginationWithState({
@@ -47,15 +53,18 @@ const useProjectListPage = (): UseProjectListPageReturn => {
data: projects = [],
isLoading: isProjectsLoading,
isError: isProjectsError,
- } = useGetFilteredProjectsByPage(currentFilter, currentPage, ITEMS_PER_PAGE);
+ } = useProjectsByPage(currentFilter, currentPage, ITEMS_PER_PAGE);
const isLoading = isProjectsLoading || isCountLoading;
const isError = isProjectsError || isCountError;
- const handleSearch = (filter: ProjectSearchFilterOption): void => {
- setCurrentFilter(filter);
- goToReset();
- };
+ const handleSearch = useCallback(
+ (filter: ProjectSearchFilterOption): void => {
+ setCurrentFilter(filter);
+ goToReset();
+ },
+ [goToReset]
+ );
const handlePageChange = (page: number): void => {
if (page === currentPage || isLoading) return;
@@ -78,4 +87,4 @@ const useProjectListPage = (): UseProjectListPageReturn => {
};
};
-export default useProjectListPage;
+export default useProjectSearch;
diff --git a/src/entities/search/hooks/useSearchHistory.ts b/src/entities/search/hooks/useSearchHistory.ts
new file mode 100644
index 0000000..b5ef075
--- /dev/null
+++ b/src/entities/search/hooks/useSearchHistory.ts
@@ -0,0 +1,65 @@
+import { useCallback } from "react";
+
+import {
+ useSearchHistory as useSearchHistoryStore,
+ useSearchHistoryActions,
+ useIsHistoryEnabled,
+} from "@shared/stores/searchStore";
+
+interface UseSearchHistoryReturn {
+ searchHistory: string[];
+ isHistoryEnabled: boolean;
+ handleRemoveHistoryItem: (historyItem: string, e: React.MouseEvent) => void;
+ handleClearAllHistory: () => void;
+ handleToggleHistory: () => void;
+ handleHistoryItemClick: (
+ historyItem: string,
+ onItemClick: (item: string) => void,
+ onClose: () => void
+ ) => void;
+}
+
+export const useSearchHistory = (): UseSearchHistoryReturn => {
+ const searchHistory = useSearchHistoryStore();
+ const isHistoryEnabled = useIsHistoryEnabled();
+ const { clearHistory, removeFromHistory, toggleHistoryEnabled } =
+ useSearchHistoryActions();
+
+ const handleRemoveHistoryItem = useCallback(
+ (historyItem: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+ removeFromHistory(historyItem);
+ },
+ [removeFromHistory]
+ );
+
+ const handleClearAllHistory = useCallback(() => {
+ clearHistory();
+ }, [clearHistory]);
+
+ const handleToggleHistory = useCallback(() => {
+ toggleHistoryEnabled();
+ }, [toggleHistoryEnabled]);
+
+ const handleHistoryItemClick = useCallback(
+ (
+ historyItem: string,
+ onItemClick: (item: string) => void,
+ onClose: () => void
+ ) => {
+ onItemClick(historyItem);
+ onClose();
+ },
+ []
+ );
+
+ return {
+ searchHistory,
+ isHistoryEnabled,
+ handleRemoveHistoryItem,
+ handleClearAllHistory,
+ handleToggleHistory,
+ handleHistoryItemClick,
+ };
+};
diff --git a/src/entities/search/hooks/useSearchInput.ts b/src/entities/search/hooks/useSearchInput.ts
new file mode 100644
index 0000000..13a5247
--- /dev/null
+++ b/src/entities/search/hooks/useSearchInput.ts
@@ -0,0 +1,114 @@
+import { useCallback, useState, useRef } from "react";
+
+import {
+ useSearchTitle,
+ useSearchTitleActions,
+ useSearchHistoryActions,
+} from "@shared/stores/searchStore";
+
+interface UseSearchInputReturn {
+ title: string;
+ isHistoryOpen: boolean;
+ isFocused: boolean;
+ inputRef: React.RefObject;
+ containerRef: React.RefObject;
+ showHistory: boolean;
+ handleKeyDown: (e: React.KeyboardEvent) => void;
+ handleFocus: () => void;
+ handleBlur: () => void;
+ handleHistoryItemClick: (historyItem: string) => void;
+ handleHistoryClose: () => void;
+ handleClickAway: (event: Event | React.SyntheticEvent) => void;
+ updateTitle: (title: string) => void;
+}
+
+interface UseSearchInputProps {
+ onEnterPress?: () => void;
+}
+
+export const useSearchInput = ({
+ onEnterPress,
+}: UseSearchInputProps): UseSearchInputReturn => {
+ const title = useSearchTitle();
+ const { updateTitle } = useSearchTitleActions();
+ const { addToHistory } = useSearchHistoryActions();
+
+ const [isHistoryOpen, setIsHistoryOpen] = useState(false);
+ const [isFocused, setIsFocused] = useState(false);
+ const inputRef = useRef(null);
+ const containerRef = useRef(null);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && onEnterPress) {
+ e.preventDefault();
+ if (title.trim()) {
+ addToHistory(title);
+ }
+ onEnterPress();
+ setIsHistoryOpen(false);
+ } else if (e.key === "Escape") {
+ setIsHistoryOpen(false);
+ inputRef.current?.blur();
+ }
+ },
+ [onEnterPress, title, addToHistory]
+ );
+
+ const handleFocus = useCallback(() => {
+ setIsFocused(true);
+ setIsHistoryOpen(true);
+ }, []);
+
+ const handleBlur = useCallback(() => {
+ setTimeout(() => {
+ setIsFocused(false);
+ }, 150);
+ }, []);
+
+ const handleHistoryItemClick = useCallback(
+ (historyItem: string) => {
+ updateTitle(historyItem);
+ inputRef.current?.focus();
+ },
+ [updateTitle]
+ );
+
+ const handleHistoryClose = useCallback(() => {
+ setIsHistoryOpen(false);
+ }, []);
+
+ const handleClickAway = useCallback((event: Event | React.SyntheticEvent) => {
+ const target = event.target as Element;
+
+ const isInsideDropdown =
+ target?.closest("[data-history-dropdown]") ||
+ target?.hasAttribute("data-history-dropdown") ||
+ target?.parentElement?.closest("[data-history-dropdown]") ||
+ containerRef.current?.contains(target as Node);
+
+ if (isInsideDropdown) {
+ return;
+ }
+
+ setIsHistoryOpen(false);
+ }, []);
+
+ const showHistory = isHistoryOpen && isFocused;
+
+ return {
+ title,
+ isHistoryOpen,
+ isFocused,
+ inputRef,
+ containerRef,
+ showHistory,
+ handleKeyDown,
+ handleFocus,
+ handleBlur,
+ handleHistoryItemClick,
+ handleHistoryClose,
+ handleClickAway,
+ updateTitle,
+ };
+};
diff --git a/src/entities/search/model/searchOptions.ts b/src/entities/search/model/searchConstants.ts
similarity index 100%
rename from src/entities/search/model/searchOptions.ts
rename to src/entities/search/model/searchConstants.ts
diff --git a/src/entities/search/model/selectFieldConfigs.ts b/src/entities/search/model/searchFormConfig.ts
similarity index 97%
rename from src/entities/search/model/selectFieldConfigs.ts
rename to src/entities/search/model/searchFormConfig.ts
index d0045fb..b2836c4 100644
--- a/src/entities/search/model/selectFieldConfigs.ts
+++ b/src/entities/search/model/searchFormConfig.ts
@@ -3,7 +3,7 @@ import {
POSITION_OPTIONS,
SEARCH_FORM_LABELS,
SORT_OPTIONS,
-} from "@entities/search/model/searchOptions";
+} from "@entities/search/model/searchConstants";
import {
ProjectCategory,
diff --git a/src/entities/search/model/searchFilterBuilder.ts b/src/entities/search/model/searchQueryBuilder.ts
similarity index 88%
rename from src/entities/search/model/searchFilterBuilder.ts
rename to src/entities/search/model/searchQueryBuilder.ts
index 7d1b720..c5d52f1 100644
--- a/src/entities/search/model/searchFilterBuilder.ts
+++ b/src/entities/search/model/searchQueryBuilder.ts
@@ -10,8 +10,6 @@ import {
type DocumentData,
} from "firebase/firestore";
-import type { ProjectSearchFilterOption, SortBy } from "@entities/search/types";
-
import { db } from "@shared/firebase/firebase";
import type { FilterBuilder } from "@shared/types/firebase";
import type {
@@ -19,9 +17,10 @@ import type {
RecruitmentStatus,
Workflow,
} from "@shared/types/project";
+import type { ProjectSearchFilterOption, SortBy } from "@shared/types/search";
import type { UserRole } from "@shared/types/user";
-export class SearchFilterBuilder implements FilterBuilder {
+export class SearchQueryBuilder implements FilterBuilder {
private filter: ProjectSearchFilterOption;
private baseQuery: Query;
private limitValue?: number;
@@ -87,15 +86,6 @@ export class SearchFilterBuilder implements FilterBuilder {
build(): Query {
let builtQuery: Query = this.baseQuery;
- if (this.filter.title) {
- const titleLower = this.filter.title.toLowerCase();
- builtQuery = query(
- builtQuery,
- where("title", ">=", titleLower),
- where("title", "<", titleLower + "\uf8ff")
- );
- }
-
if (this.filter.category) {
builtQuery = query(
builtQuery,
diff --git a/src/entities/search/queries/useGetFilteredProjectLists.ts b/src/entities/search/queries/useGetFilteredProjectLists.ts
deleted file mode 100644
index 30f1691..0000000
--- a/src/entities/search/queries/useGetFilteredProjectLists.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { useQuery, type UseQueryResult } from "@tanstack/react-query";
-import type { QueryDocumentSnapshot, DocumentData } from "firebase/firestore";
-
-import getFilteredProjectLists, {
- getFilteredProjectListsSimple,
- getFilteredProjectCount,
- getFilteredProjectsByPage,
-} from "@entities/search/api/getFilteredProjectLists";
-import type { ProjectSearchFilterOption } from "@entities/search/types";
-
-import type { ProjectListRes } from "@shared/types/project";
-
-interface PaginatedSearchOptions {
- filter: ProjectSearchFilterOption;
- cursor?: QueryDocumentSnapshot | null;
- pageSize?: number;
-}
-
-interface PaginatedSearchResult {
- projects: ProjectListRes[];
- lastVisible: QueryDocumentSnapshot | null;
- hasMore: boolean;
-}
-
-const useGetFilteredProjectLists = (
- filter: ProjectSearchFilterOption,
- enabled: boolean = true
-): UseQueryResult => {
- return useQuery({
- queryKey: ["filteredProjects", filter],
- queryFn: () => getFilteredProjectListsSimple("projects", filter),
- enabled,
- staleTime: 5 * 60 * 1000,
- gcTime: 10 * 60 * 1000,
- placeholderData: (previousData) => previousData,
- });
-};
-
-export const useGetFilteredProjectListsWithPagination = (
- options: PaginatedSearchOptions,
- enabled: boolean = true
-): UseQueryResult => {
- return useQuery({
- queryKey: [
- "filteredProjectsPaginated",
- options.filter,
- options.cursor,
- options.pageSize,
- ],
- queryFn: () =>
- getFilteredProjectLists("projects", options.filter, {
- cursor: options.cursor,
- pageSize: options.pageSize,
- }),
- enabled,
- staleTime: 5 * 60 * 1000,
- gcTime: 10 * 60 * 1000,
- placeholderData: (previousData) => previousData,
- });
-};
-
-export const useGetFilteredProjectsCount = (
- filter: ProjectSearchFilterOption,
- enabled: boolean = true
-): UseQueryResult => {
- return useQuery({
- queryKey: ["filteredProjectsCount", filter],
- queryFn: () => getFilteredProjectCount("projects", filter),
- enabled,
- staleTime: 10 * 60 * 1000,
- gcTime: 15 * 60 * 1000,
- placeholderData: (previousData) => previousData,
- });
-};
-
-export const useGetFilteredProjectsByPage = (
- filter: ProjectSearchFilterOption,
- page: number,
- pageSize: number = 6,
- enabled: boolean = true
-): UseQueryResult => {
- return useQuery({
- queryKey: ["filteredProjectsByPage", filter, page, pageSize],
- queryFn: () =>
- getFilteredProjectsByPage("projects", filter, page, pageSize),
- enabled,
- staleTime: 3 * 60 * 1000,
- gcTime: 10 * 60 * 1000,
- refetchOnWindowFocus: false,
- retry: 1,
- placeholderData: (previousData) => previousData,
- });
-};
-
-export default useGetFilteredProjectLists;
diff --git a/src/entities/search/queries/useProjectSearchQueries.ts b/src/entities/search/queries/useProjectSearchQueries.ts
new file mode 100644
index 0000000..030d4f0
--- /dev/null
+++ b/src/entities/search/queries/useProjectSearchQueries.ts
@@ -0,0 +1,33 @@
+import { useQuery, type UseQueryResult } from "@tanstack/react-query";
+
+import {
+ getProjectsCount,
+ getProjectsByPage,
+} from "@entities/search/api/projectSearchApi";
+
+import type { ProjectListRes } from "@shared/types/project";
+import type { ProjectSearchFilterOption } from "@shared/types/search";
+
+const STALE_TIME_MINUTES = 5;
+
+export const useProjectsCount = (
+ filter: ProjectSearchFilterOption
+): UseQueryResult => {
+ return useQuery({
+ queryKey: ["projects", "count", filter],
+ queryFn: () => getProjectsCount("projects", filter),
+ staleTime: 1000 * 60 * STALE_TIME_MINUTES,
+ });
+};
+
+export const useProjectsByPage = (
+ filter: ProjectSearchFilterOption,
+ page: number,
+ pageSize: number
+): UseQueryResult => {
+ return useQuery({
+ queryKey: ["projects", "page", filter, page, pageSize],
+ queryFn: () => getProjectsByPage("projects", filter, page, pageSize),
+ staleTime: 1000 * 60 * STALE_TIME_MINUTES,
+ });
+};
diff --git a/src/entities/search/ui/ProjectSearchForm.tsx b/src/entities/search/ui/ProjectSearchForm.tsx
deleted file mode 100644
index d4e676b..0000000
--- a/src/entities/search/ui/ProjectSearchForm.tsx
+++ /dev/null
@@ -1,369 +0,0 @@
-import FilterListIcon from "@mui/icons-material/FilterList";
-import Search from "@mui/icons-material/Search";
-import TuneIcon from "@mui/icons-material/Tune";
-import {
- Box,
- Button,
- TextField,
- Paper,
- alpha,
- Typography,
- Chip,
- styled,
- useMediaQuery,
- useTheme,
- Divider,
-} from "@mui/material";
-import type { JSX } from "react";
-
-import useFilteredProjects from "@entities/search/hooks/useFilteredProjects";
-import { SELECT_FIELD_CONFIGS } from "@entities/search/model/selectFieldConfigs";
-import type { ProjectSearchFilterOption } from "@entities/search/types";
-import ProjectSearchSelectBox from "@entities/search/ui/project-search-input/ProjectSearchSelectBox";
-
-interface ProjectSearchFormProps {
- onSearch: (filter: ProjectSearchFilterOption) => void;
- isLoading?: boolean;
-}
-
-const ProjectSearchForm = ({
- onSearch,
- isLoading = false,
-}: ProjectSearchFormProps): JSX.Element => {
- const theme = useTheme();
- const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
-
- const {
- title,
- category,
- position,
- status,
- workflow,
- sortBy,
- updateTitle,
- updateCategory,
- updatePosition,
- updateStatus,
- updateWorkflow,
- updateSortBy,
- resetFilters,
- getActiveFiltersCount,
- getCleanFilter,
- } = useFilteredProjects();
-
- const activeFiltersCount = getActiveFiltersCount();
-
- return (
-
-
-
-
-
- 프로젝트 찾기
-
-
- 원하는 조건으로 프로젝트를 검색하고 필터링하세요
-
-
-
-
-
- {activeFiltersCount > 0 && (
- }
- label={`${activeFiltersCount}개 활성 필터`}
- size="medium"
- />
- )}
- }
- onClick={resetFilters}
- disabled={isLoading}
- >
- 필터 초기화
-
-
-
-
-
-
- updateTitle(e.target.value)}
- variant="outlined"
- InputProps={{
- startAdornment: ,
- }}
- />
-
-
-
-
-
- 상세 필터
-
-
-
-
- updateCategory(value as any)}
- />
-
- updatePosition(value as any)}
- />
-
- updateStatus(value as any)}
- />
-
- updateWorkflow(value as any)}
- />
-
- updateSortBy(value as any)}
- />
-
-
-
-
- }
- onClick={() => onSearch(getCleanFilter())}
- disabled={isLoading}
- >
- {isLoading ? "검색 중..." : "프로젝트 검색"}
-
-
-
- );
-};
-
-export default ProjectSearchForm;
-
-const StyledContainer = styled(Paper)(({ theme }) => ({
- width: "100%",
- borderRadius: theme.spacing(3),
- overflow: "hidden",
- marginBottom: theme.spacing(4),
- backgroundColor: theme.palette.background.paper,
- border: `1px solid ${alpha(theme.palette.primary.main, 0.08)}`,
- boxShadow: `0 8px 32px ${alpha(theme.palette.common.black, 0.04)}`,
-}));
-
-const HeaderSection = styled(Box)(({ theme }) => ({
- display: "flex",
- justifyContent: "space-between",
- alignItems: "flex-start",
- padding: theme.spacing(4),
- backgroundColor: theme.palette.background.paper,
-
- [theme.breakpoints.down("md")]: {
- flexDirection: "column",
- gap: theme.spacing(3),
- alignItems: "stretch",
- },
-}));
-
-const TitleArea = styled(Box)(() => ({
- display: "flex",
- alignItems: "center",
-}));
-
-const TextContainer = styled(Box)(() => ({
- display: "flex",
- flexDirection: "column",
- gap: 4,
-}));
-
-const MainTitle = styled(Typography)(({ theme }) => ({
- fontWeight: 800,
- color: theme.palette.text.primary,
- letterSpacing: "-0.02em",
-}));
-
-const SubTitle = styled(Typography)(({ theme }) => ({
- color: theme.palette.text.secondary,
- fontWeight: 500,
-}));
-
-const StatusArea = styled(Box)(({ theme }) => ({
- display: "flex",
- alignItems: "center",
- gap: theme.spacing(2),
-
- [theme.breakpoints.down("md")]: {
- justifyContent: "space-between",
- },
-}));
-
-const ActiveFiltersChip = styled(Chip)(({ theme }) => ({
- fontWeight: 600,
- fontSize: "0.875rem",
- height: 40,
- backgroundColor: alpha(theme.palette.primary.main, 0.12),
- color: theme.palette.primary.main,
- border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`,
-
- "& .MuiChip-icon": {
- color: theme.palette.primary.main,
- },
-}));
-
-const ResetButton = styled(Button)(({ theme }) => ({
- height: 40,
- fontWeight: 600,
- borderRadius: theme.spacing(1.5),
- borderColor: alpha(theme.palette.text.secondary, 0.3),
- color: theme.palette.text.secondary,
-
- "&:hover": {
- borderColor: theme.palette.primary.main,
- backgroundColor: alpha(theme.palette.primary.main, 0.04),
- color: theme.palette.primary.main,
- },
-}));
-
-const SearchSection = styled(Box)(({ theme }) => ({
- padding: `${theme.spacing(2)} ${theme.spacing(4)}`,
- backgroundColor: theme.palette.background.paper,
-}));
-
-const SearchContainer = styled(Box)(() => ({
- width: "100%",
-}));
-
-const StyledTextField = styled(TextField)(({ theme }) => ({
- "& .MuiOutlinedInput-root": {
- borderRadius: theme.spacing(2),
- fontSize: "1.6rem",
- backgroundColor: theme.palette.background.paper,
- transition: "all 0.2s ease-in-out",
- padding: theme.spacing(0.5, 1.5),
-
- "&:hover": {
- boxShadow: `0 4px 20px ${alpha(theme.palette.primary.main, 0.08)}`,
-
- "& .MuiOutlinedInput-notchedOutline": {
- borderColor: alpha(theme.palette.primary.main, 0.4),
- },
- },
-
- "&.Mui-focused": {
- boxShadow: `0 4px 24px ${alpha(theme.palette.primary.main, 0.12)}`,
-
- "& .MuiOutlinedInput-notchedOutline": {
- borderColor: theme.palette.primary.main,
- borderWidth: 2,
- },
- },
- },
-
- "& .MuiInputLabel-root": {
- fontWeight: 700,
- fontSize: "1.5rem",
- },
-
- "& .MuiOutlinedInput-input": {
- fontSize: "1.5rem",
- padding: theme.spacing(2.5),
- fontWeight: 500,
- },
-}));
-
-const StyledSearchIcon = styled(Search)(({ theme }) => ({
- color: theme.palette.text.secondary,
- marginRight: theme.spacing(1.5),
- fontSize: "1.5rem",
-}));
-
-const FiltersSection = styled(Box)(({ theme }) => ({
- padding: `0 ${theme.spacing(4)} ${theme.spacing(3)}`,
- backgroundColor: theme.palette.background.paper,
-}));
-
-const SectionHeader = styled(Box)(({ theme }) => ({
- display: "flex",
- alignItems: "center",
- marginBottom: theme.spacing(3),
- gap: theme.spacing(2),
-}));
-
-const SectionTitle = styled(Typography)(({ theme }) => ({
- fontWeight: 700,
- color: theme.palette.text.primary,
- flexShrink: 0,
-}));
-
-const SectionDivider = styled(Divider)(({ theme }) => ({
- flex: 1,
- backgroundColor: alpha(theme.palette.primary.main, 0.1),
-}));
-
-const FiltersGrid = styled(Box)(({ theme }) => ({
- display: "grid",
- gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
- gap: theme.spacing(3),
-
- [theme.breakpoints.up("md")]: {
- gridTemplateColumns: "repeat(3, 1fr)",
- },
-
- [theme.breakpoints.up("lg")]: {
- gridTemplateColumns: "repeat(5, 1fr)",
- },
-}));
-
-const ActionSection = styled(Box)(({ theme }) => ({
- padding: theme.spacing(4),
- paddingTop: theme.spacing(2),
- display: "flex",
- justifyContent: "center",
- backgroundColor: theme.palette.background.paper,
-}));
-
-const SearchButton = styled(Button)(({ theme }) => ({
- minWidth: 240,
- height: 56,
- fontSize: "1.1rem",
- fontWeight: 700,
- borderRadius: theme.spacing(2),
- background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
- boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.3)}`,
- transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
- textTransform: "none",
-
- "&:hover": {
- transform: "translateY(-2px)",
- boxShadow: `0 12px 32px ${alpha(theme.palette.primary.main, 0.4)}`,
- background: `linear-gradient(135deg, ${theme.palette.primary.dark} 0%, ${theme.palette.primary.main} 100%)`,
- },
-
- "&:active": {
- transform: "translateY(0px)",
- },
-
- "&:disabled": {
- transform: "none",
- background: theme.palette.action.disabledBackground,
- boxShadow: "none",
- },
-}));
diff --git a/src/entities/search/ui/SearchActions.tsx b/src/entities/search/ui/SearchActions.tsx
new file mode 100644
index 0000000..6023945
--- /dev/null
+++ b/src/entities/search/ui/SearchActions.tsx
@@ -0,0 +1,131 @@
+import { Button, Chip, alpha, styled } from "@mui/material";
+import { memo } from "react";
+import type { JSX } from "react";
+
+import {
+ FilterListIcon,
+ SearchIcon as Search,
+ TuneIcon,
+} from "@shared/ui/icons/CommonIcons";
+
+export const MemoizedActiveFiltersChip = memo(
+ ({ count }: { count: number }): JSX.Element => (
+ }
+ label={`${count}개 활성 필터`}
+ size="medium"
+ />
+ )
+);
+
+export const MemoizedResetButton = memo(
+ ({
+ onClick,
+ disabled,
+ }: {
+ onClick: () => void;
+ disabled: boolean;
+ }): JSX.Element => (
+ }
+ onClick={onClick}
+ disabled={disabled}
+ >
+ 필터 초기화
+
+ )
+);
+
+export const MemoizedSearchButton = memo(
+ ({
+ isLoading,
+ isMobile,
+ }: {
+ isLoading: boolean;
+ isMobile: boolean;
+ }): JSX.Element => (
+ }
+ disabled={isLoading}
+ >
+ {isLoading ? "검색 중..." : "프로젝트 검색"}
+
+ )
+);
+
+const ActiveFiltersChip = styled(Chip)(({ theme }) => ({
+ fontWeight: 600,
+ fontSize: "1.4rem",
+ height: 40,
+ backgroundColor: alpha(theme.palette.primary.main, 0.12),
+ color: theme.palette.primary.main,
+ border: `1px solid ${alpha(theme.palette.primary.main, 0.2)}`,
+
+ "& .MuiChip-icon": {
+ color: theme.palette.primary.main,
+ },
+
+ [theme.breakpoints.down("sm")]: {
+ fontSize: "1.2rem",
+ },
+}));
+
+const ResetButton = styled(Button)(({ theme }) => ({
+ height: 40,
+ fontWeight: 600,
+ borderRadius: theme.spacing(1.5),
+ borderColor: alpha(theme.palette.text.secondary, 0.3),
+ color: theme.palette.text.secondary,
+
+ "&:hover": {
+ borderColor: theme.palette.primary.main,
+ backgroundColor: alpha(theme.palette.primary.main, 0.04),
+ color: theme.palette.primary.main,
+ },
+
+ [theme.breakpoints.down("sm")]: {
+ height: 36,
+ fontSize: "1.2rem",
+ marginLeft: "auto",
+ },
+}));
+
+const SearchButton = styled(Button)(({ theme }) => ({
+ minWidth: 240,
+ height: 56,
+ fontSize: "1.1rem",
+ fontWeight: 700,
+ borderRadius: theme.spacing(2),
+ background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
+ boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.3)}`,
+ transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
+ textTransform: "none",
+
+ [theme.breakpoints.down("sm")]: {
+ minWidth: "auto",
+ height: 52,
+ fontSize: "1.2rem",
+ padding: theme.spacing(1.5, 2),
+ },
+
+ "&:hover": {
+ transform: "translateY(-2px)",
+ boxShadow: `0 12px 32px ${alpha(theme.palette.primary.main, 0.4)}`,
+ background: `linear-gradient(135deg, ${theme.palette.primary.dark} 0%, ${theme.palette.primary.main} 100%)`,
+ },
+
+ "&:active": {
+ transform: "translateY(0px)",
+ },
+
+ "&:disabled": {
+ transform: "none",
+ background: theme.palette.action.disabledBackground,
+ boxShadow: "none",
+ },
+}));
diff --git a/src/entities/search/ui/SearchFilters.tsx b/src/entities/search/ui/SearchFilters.tsx
new file mode 100644
index 0000000..fca2ec8
--- /dev/null
+++ b/src/entities/search/ui/SearchFilters.tsx
@@ -0,0 +1,106 @@
+import { Box, styled } from "@mui/material";
+import { memo } from "react";
+import type { JSX } from "react";
+
+import { SELECT_FIELD_CONFIGS } from "@entities/search/model/searchFormConfig";
+import SelectBox from "@entities/search/ui/SelectBox";
+
+import {
+ useSearchCategory,
+ useSearchPosition,
+ useSearchStatus,
+ useSearchWorkflow,
+ useSearchSortBy,
+ useSearchFilterActions,
+} from "@shared/stores/searchStore";
+
+export const MemoizedCategoryFilter = memo((): JSX.Element => {
+ const category = useSearchCategory();
+ const { updateCategory } = useSearchFilterActions();
+
+ return (
+ void}
+ />
+ );
+});
+
+export const MemoizedPositionFilter = memo((): JSX.Element => {
+ const position = useSearchPosition();
+ const { updatePosition } = useSearchFilterActions();
+
+ return (
+ void}
+ />
+ );
+});
+
+export const MemoizedStatusFilter = memo((): JSX.Element => {
+ const status = useSearchStatus();
+ const { updateStatus } = useSearchFilterActions();
+
+ return (
+ void}
+ />
+ );
+});
+
+export const MemoizedWorkflowFilter = memo((): JSX.Element => {
+ const workflow = useSearchWorkflow();
+ const { updateWorkflow } = useSearchFilterActions();
+
+ return (
+ void}
+ />
+ );
+});
+
+export const MemoizedSortByFilter = memo((): JSX.Element => {
+ const sortBy = useSearchSortBy();
+ const { updateSortBy } = useSearchFilterActions();
+
+ return (
+ void}
+ />
+ );
+});
+
+export const MemoizedFiltersGrid = memo(
+ (): JSX.Element => (
+
+
+
+
+
+
+
+ )
+);
+
+const FiltersGrid = styled(Box)(({ theme }) => ({
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
+ gap: theme.spacing(3),
+
+ [theme.breakpoints.up("md")]: {
+ gridTemplateColumns: "repeat(3, 1fr)",
+ },
+
+ [theme.breakpoints.up("lg")]: {
+ gridTemplateColumns: "repeat(5, 1fr)",
+ },
+}));
diff --git a/src/entities/search/ui/SearchForm.tsx b/src/entities/search/ui/SearchForm.tsx
new file mode 100644
index 0000000..63372d2
--- /dev/null
+++ b/src/entities/search/ui/SearchForm.tsx
@@ -0,0 +1,163 @@
+import {
+ Box,
+ Paper,
+ alpha,
+ styled,
+ useMediaQuery,
+ useTheme,
+} from "@mui/material";
+import type { JSX } from "react";
+import { memo, useCallback } from "react";
+
+import {
+ MemoizedActiveFiltersChip,
+ MemoizedResetButton,
+ MemoizedSearchButton,
+} from "@entities/search/ui/SearchActions";
+import { MemoizedFiltersGrid } from "@entities/search/ui/SearchFilters";
+import SearchInput from "@entities/search/ui/SearchInput";
+import {
+ MemoizedTitleSection,
+ MemoizedSectionHeader,
+} from "@entities/search/ui/SearchLabels";
+
+import {
+ useSearchUtils,
+ useActiveFiltersCount,
+ useSearchTitle,
+ useSearchHistoryActions,
+} from "@shared/stores/searchStore";
+import type { ProjectSearchFilterOption } from "@shared/types/search";
+
+interface SearchFormProps {
+ onSearch: (filter: ProjectSearchFilterOption) => void;
+ isLoading?: boolean;
+}
+
+const SearchForm = memo(
+ ({ onSearch, isLoading = false }: SearchFormProps): JSX.Element => {
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
+
+ const title = useSearchTitle();
+
+ const { resetFilters, getFilterStatus } = useSearchUtils();
+ const { addToHistory } = useSearchHistoryActions();
+
+ const activeFiltersCount = useActiveFiltersCount();
+
+ const handleSearch = useCallback((): void => {
+ const searchFilter = getFilterStatus();
+
+ if (title.trim()) {
+ addToHistory(title);
+ }
+
+ onSearch(searchFilter);
+ }, [getFilterStatus, title, addToHistory, onSearch]);
+
+ return (
+ {
+ e.preventDefault();
+ handleSearch();
+ }}
+ >
+
+
+
+
+ 0}>
+ {activeFiltersCount > 0 && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+);
+
+export default SearchForm;
+
+const StyledForm = styled("form")(() => ({
+ width: "100%",
+}));
+
+const StyledContainer = styled(Paper)(({ theme }) => ({
+ width: "100%",
+ borderRadius: theme.spacing(3),
+ marginBottom: theme.spacing(4),
+ backgroundColor: theme.palette.background.paper,
+ border: `1px solid ${alpha(theme.palette.primary.main, 0.08)}`,
+ boxShadow: `0 8px 32px ${alpha(theme.palette.common.black, 0.04)}`,
+}));
+
+const HeaderSection = styled(Box)(({ theme }) => ({
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "flex-start",
+ padding: theme.spacing(4),
+ backgroundColor: theme.palette.background.paper,
+
+ [theme.breakpoints.down("md")]: {
+ flexDirection: "column",
+ gap: theme.spacing(3),
+ alignItems: "stretch",
+ },
+}));
+
+const StatusArea = styled(Box, {
+ shouldForwardProp: (prop) => prop !== "$hasActiveFilters",
+})<{ $hasActiveFilters: boolean }>(({ theme, $hasActiveFilters }) => ({
+ display: "flex",
+ alignItems: "center",
+ gap: theme.spacing(2),
+
+ [theme.breakpoints.down("md")]: {
+ justifyContent: $hasActiveFilters ? "space-between" : "flex-end",
+ },
+}));
+
+const SearchSection = styled(Box)(({ theme }) => ({
+ padding: `${theme.spacing(2)} ${theme.spacing(4)}`,
+ backgroundColor: theme.palette.background.paper,
+}));
+
+const SearchContainer = styled(Box)(() => ({
+ width: "100%",
+}));
+
+const FiltersSection = styled(Box)(({ theme }) => ({
+ padding: `0 ${theme.spacing(4)} ${theme.spacing(3)}`,
+ backgroundColor: theme.palette.background.paper,
+}));
+
+const ActionSection = styled(Box)(({ theme }) => ({
+ padding: theme.spacing(4),
+ paddingTop: theme.spacing(2),
+ display: "flex",
+ justifyContent: "center",
+ backgroundColor: theme.palette.background.paper,
+}));
diff --git a/src/entities/search/ui/SearchInput.tsx b/src/entities/search/ui/SearchInput.tsx
new file mode 100644
index 0000000..fc6c60c
--- /dev/null
+++ b/src/entities/search/ui/SearchInput.tsx
@@ -0,0 +1,91 @@
+import { TextField, Box, ClickAwayListener } from "@mui/material";
+import { styled } from "@mui/material/styles";
+import { memo } from "react";
+import type { JSX } from "react";
+
+import { useSearchInput } from "@entities/search/hooks/useSearchInput";
+import SearchInputHistory from "@entities/search/ui/SearchInputHistory";
+
+import { SearchIcon } from "@shared/ui/icons/CommonIcons";
+
+interface SearchInputProps {
+ onEnterPress?: () => void;
+}
+
+const SearchInput = memo(({ onEnterPress }: SearchInputProps): JSX.Element => {
+ const {
+ title,
+ inputRef,
+ containerRef,
+ showHistory,
+ handleKeyDown,
+ handleFocus,
+ handleBlur,
+ handleHistoryItemClick,
+ handleHistoryClose,
+ handleClickAway,
+ updateTitle,
+ } = useSearchInput({ onEnterPress });
+
+ return (
+
+
+ updateTitle(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ variant="outlined"
+ autoComplete="off"
+ showHistory={showHistory}
+ InputProps={{
+ startAdornment: ,
+ }}
+ />
+
+ {showHistory && (
+
+ )}
+
+
+ );
+});
+
+const SearchContainer = styled(Box)(() => ({
+ position: "relative",
+ width: "100%",
+}));
+
+const StyledSearchIcon = styled(SearchIcon)(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ marginRight: theme.spacing(1.5),
+ fontSize: "1.5rem",
+}));
+
+const StyledTextField = styled(TextField, {
+ shouldForwardProp: (prop) => prop !== "showHistory",
+})<{ showHistory: boolean }>(({ theme, showHistory }) => ({
+ "& .MuiOutlinedInput-root": {
+ borderRadius: theme.spacing(1.5),
+ backgroundColor: theme.palette.background.paper,
+ transition: "all 0.2s ease-in-out",
+ borderBottomLeftRadius: showHistory ? 0 : undefined,
+ borderBottomRightRadius: showHistory ? 0 : undefined,
+ "&:hover": {
+ boxShadow: `0 2px 8px ${theme.palette.action.hover}`,
+ },
+ "&.Mui-focused": {
+ boxShadow: `0 4px 12px ${theme.palette.primary.main}25`,
+ },
+ },
+}));
+
+export default SearchInput;
diff --git a/src/entities/search/ui/SearchInputHistory.tsx b/src/entities/search/ui/SearchInputHistory.tsx
new file mode 100644
index 0000000..b492e23
--- /dev/null
+++ b/src/entities/search/ui/SearchInputHistory.tsx
@@ -0,0 +1,344 @@
+import {
+ Paper,
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ IconButton,
+ Typography,
+ Divider,
+ Box,
+ Switch,
+ FormControlLabel,
+ alpha,
+} from "@mui/material";
+import { styled } from "@mui/material/styles";
+import { memo } from "react";
+import type { JSX } from "react";
+
+import { useSearchHistory } from "@entities/search/hooks/useSearchHistory";
+import SearchInputHistoryToggle from "@entities/search/ui/SearchInputHistoryToggle";
+
+import {
+ HistoryIcon as History,
+ HistoryToggleOffIcon as HistoryToggleOff,
+ ClearIcon as Clear,
+} from "@shared/ui/icons/CommonIcons";
+
+interface SearchInputHistoryProps {
+ onItemClick: (item: string) => void;
+ onClose: () => void;
+}
+
+const HistoryDisabledMessage = (): JSX.Element => (
+
+
+
+ 검색 히스토리가 비활성화되어 있습니다
+
+
+);
+
+const HistoryEmptyMessage = (): JSX.Element => (
+
+
+
+ 검색 히스토리가 없습니다
+
+
+);
+
+interface HistoryListContentProps {
+ searchHistory: string[];
+ onItemClick: (item: string) => void;
+ onClose: () => void;
+ handleHistoryItemClick: (
+ historyItem: string,
+ onItemClick: (item: string) => void,
+ onClose: () => void
+ ) => void;
+ handleRemoveHistoryItem: (historyItem: string, e: React.MouseEvent) => void;
+}
+
+const HistoryListContent = ({
+ searchHistory,
+ onItemClick,
+ onClose,
+ handleHistoryItemClick,
+ handleRemoveHistoryItem,
+}: HistoryListContentProps): JSX.Element => (
+ <>
+ {searchHistory.map((historyItem, index) => (
+
+
+ handleHistoryItemClick(historyItem, onItemClick, onClose)
+ }
+ >
+
+
+
+
+ handleRemoveHistoryItem(historyItem, e)}
+ onMouseDown={(e) => e.stopPropagation()}
+ data-history-dropdown
+ title="삭제"
+ >
+
+
+
+
+ ))}
+ >
+);
+// 헤더 컴포넌트
+interface SearchHistoryHeaderProps {
+ isHistoryEnabled: boolean;
+ searchHistory: string[];
+ handleToggleHistory: (event: React.ChangeEvent) => void;
+ handleClearAllHistory: () => void;
+}
+
+const SearchHistoryHeader = ({
+ isHistoryEnabled,
+ searchHistory,
+ handleToggleHistory,
+ handleClearAllHistory,
+}: SearchHistoryHeaderProps): JSX.Element => (
+
+
+
+
+ 검색 히스토리
+
+
+
+
+ }
+ label=""
+ onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
+ data-history-dropdown
+ />
+ {isHistoryEnabled && searchHistory.length > 0 && (
+ e.preventDefault()}
+ data-history-dropdown
+ >
+ 전체삭제
+
+ )}
+
+
+);
+
+interface HistoryContentProps {
+ isHistoryEnabled: boolean;
+ searchHistory: string[];
+ onItemClick: (item: string) => void;
+ onClose: () => void;
+ handleHistoryItemClick: (
+ historyItem: string,
+ onItemClick: (item: string) => void,
+ onClose: () => void
+ ) => void;
+ handleRemoveHistoryItem: (historyItem: string, e: React.MouseEvent) => void;
+}
+
+const HistoryContent = ({
+ isHistoryEnabled,
+ searchHistory,
+ onItemClick,
+ onClose,
+ handleHistoryItemClick,
+ handleRemoveHistoryItem,
+}: HistoryContentProps): JSX.Element => {
+ if (!isHistoryEnabled) {
+ return ;
+ }
+
+ if (searchHistory.length === 0) {
+ return ;
+ }
+
+ return (
+
+ );
+};
+
+const SearchInputHistory = memo(
+ ({
+ onItemClick: _onItemClick,
+ onClose: _onClose,
+ }: SearchInputHistoryProps): JSX.Element => {
+ const {
+ searchHistory,
+ isHistoryEnabled,
+ handleRemoveHistoryItem,
+ handleClearAllHistory,
+ handleToggleHistory,
+ handleHistoryItemClick,
+ } = useSearchHistory();
+
+ return (
+ e.preventDefault()}
+ >
+
+
+
+
+
+
+
+
+ );
+ }
+);
+
+const HistoryDropdown = styled(Paper)(({ theme }) => ({
+ position: "absolute",
+ top: "100%",
+ left: 0,
+ right: 0,
+ zIndex: 1300,
+ borderTopLeftRadius: 0,
+ borderTopRightRadius: 0,
+ borderBottomLeftRadius: theme.spacing(1.5),
+ borderBottomRightRadius: theme.spacing(1.5),
+ overflow: "hidden",
+ border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
+ borderTop: "none",
+ boxShadow: `0 8px 32px ${alpha(theme.palette.common.black, 0.12)}`,
+ backgroundColor: theme.palette.background.paper,
+}));
+
+const HistoryHeader = styled(Box)(({ theme }) => ({
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ padding: theme.spacing(1.5, 2),
+ backgroundColor: alpha(theme.palette.primary.main, 0.04),
+}));
+
+const HeaderContent = styled(Box)(({ theme }) => ({
+ display: "flex",
+ alignItems: "center",
+ gap: theme.spacing(1),
+ color: theme.palette.text.secondary,
+}));
+
+const HeaderActions = styled(Box)(({ theme }) => ({
+ display: "flex",
+ alignItems: "center",
+ gap: theme.spacing(1),
+}));
+
+const DisabledMessage = styled(Box)(({ theme }) => ({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: theme.spacing(3, 2),
+ gap: theme.spacing(1),
+ textAlign: "center",
+}));
+
+const EmptyMessage = styled(Box)(({ theme }) => ({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: theme.spacing(3, 2),
+ gap: theme.spacing(1),
+ textAlign: "center",
+}));
+
+const HistoryList = styled(List)(() => ({
+ padding: 0,
+}));
+
+const HistoryListItem = styled(ListItem)(({ theme }) => ({
+ "&:hover": {
+ backgroundColor: alpha(theme.palette.primary.main, 0.04),
+ },
+}));
+
+const HistoryListItemButton = styled(ListItemButton)(({ theme }) => ({
+ paddingLeft: theme.spacing(2),
+ paddingRight: theme.spacing(1),
+ minHeight: 48,
+
+ "& .MuiListItemIcon-root": {
+ minWidth: 36,
+ color: theme.palette.text.secondary,
+ },
+
+ "&:hover": {
+ backgroundColor: "transparent",
+ },
+}));
+
+const StyledFormControlLabel = styled(FormControlLabel)(() => ({
+ margin: 0,
+}));
+
+const ClearAllButton = styled(Box)(({ theme }) => ({
+ cursor: "pointer",
+ padding: "4px 8px",
+ borderRadius: theme.spacing(1),
+ "&:hover": {
+ backgroundColor: theme.palette.action.hover,
+ },
+}));
+
+const ClearAllText = styled(Typography)(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ fontSize: "0.75rem",
+ "&:hover": {
+ color: theme.palette.primary.main,
+ },
+}));
+
+const StyledDeleteButton = styled(IconButton)(({ theme }) => ({
+ "&:hover": {
+ backgroundColor: theme.palette.action.hover,
+ },
+}));
+
+export default SearchInputHistory;
diff --git a/src/entities/search/ui/SearchInputHistoryToggle.tsx b/src/entities/search/ui/SearchInputHistoryToggle.tsx
new file mode 100644
index 0000000..c36d176
--- /dev/null
+++ b/src/entities/search/ui/SearchInputHistoryToggle.tsx
@@ -0,0 +1,18 @@
+import { History, HistoryToggleOff } from "@mui/icons-material";
+import type { JSX } from "react";
+
+interface SearchInputHistoryToggleProps {
+ isHistoryEnabled: boolean;
+}
+
+const SearchInputHistoryToggle = ({
+ isHistoryEnabled,
+}: SearchInputHistoryToggleProps): JSX.Element => {
+ return isHistoryEnabled ? (
+
+ ) : (
+
+ );
+};
+
+export default SearchInputHistoryToggle;
diff --git a/src/entities/search/ui/SearchLabels.tsx b/src/entities/search/ui/SearchLabels.tsx
new file mode 100644
index 0000000..a5f1284
--- /dev/null
+++ b/src/entities/search/ui/SearchLabels.tsx
@@ -0,0 +1,65 @@
+import { Box, Typography, Divider, alpha, styled } from "@mui/material";
+import { memo } from "react";
+import type { JSX } from "react";
+
+export const MemoizedTitleSection = memo(
+ ({ isMobile }: { isMobile: boolean }): JSX.Element => (
+
+
+ 프로젝트 찾기
+
+ 원하는 조건으로 프로젝트를 검색하고 필터링하세요
+
+
+
+ )
+);
+
+export const MemoizedSectionHeader = memo(
+ (): JSX.Element => (
+
+ 상세 필터
+
+
+ )
+);
+
+const TitleArea = styled(Box)(() => ({
+ display: "flex",
+ alignItems: "center",
+}));
+
+const TextContainer = styled(Box)(() => ({
+ display: "flex",
+ flexDirection: "column",
+ gap: 4,
+}));
+
+const MainTitle = styled(Typography)(({ theme }) => ({
+ fontWeight: 800,
+ color: theme.palette.text.primary,
+ letterSpacing: "-0.02em",
+}));
+
+const SubTitle = styled(Typography)(({ theme }) => ({
+ color: theme.palette.text.secondary,
+ fontWeight: 500,
+}));
+
+const SectionHeader = styled(Box)(({ theme }) => ({
+ display: "flex",
+ alignItems: "center",
+ marginBottom: theme.spacing(3),
+ gap: theme.spacing(2),
+}));
+
+const SectionTitle = styled(Typography)(({ theme }) => ({
+ fontWeight: 700,
+ color: theme.palette.text.primary,
+ flexShrink: 0,
+}));
+
+const SectionDivider = styled(Divider)(({ theme }) => ({
+ flex: 1,
+ backgroundColor: alpha(theme.palette.primary.main, 0.1),
+}));
diff --git a/src/entities/search/ui/SearchListResultHandler.tsx b/src/entities/search/ui/SearchListResultHandler.tsx
new file mode 100644
index 0000000..ea7df8f
--- /dev/null
+++ b/src/entities/search/ui/SearchListResultHandler.tsx
@@ -0,0 +1,82 @@
+import { Box, Typography, styled } from "@mui/material";
+import type { JSX } from "react";
+
+import SearchLoadingSpinner from "@entities/search/ui/SearchLoadingSpinner";
+import SearchPagination from "@entities/search/ui/SearchPagination";
+
+interface SearchListResultHandlerProps {
+ isLoading: boolean;
+ isEmpty: boolean;
+ isError: boolean;
+ totalPages: number;
+ currentPage: number;
+ onPageChange: (page: number) => void;
+}
+
+const SearchListResultHandler = ({
+ isLoading,
+ isEmpty,
+ isError,
+ totalPages,
+ currentPage,
+ onPageChange,
+}: SearchListResultHandlerProps): JSX.Element | null => {
+ if (isLoading) {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ if (isEmpty) {
+ return (
+
+ 검색 조건에 맞는 프로젝트가 없습니다. 다른 조건으로 검색해보세요.
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+
+ 프로젝트를 불러오는 중 오류가 발생했습니다.
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+const EmptyState = styled(Typography)(({ theme }) => ({
+ textAlign: "center",
+ color: theme.palette.text.secondary,
+ padding: "4rem 0",
+ fontWeight: 500,
+}));
+
+const ErrorContainer = styled(Box)(({ theme }) => ({
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ padding: "4rem 0rem",
+ [theme.breakpoints.up("sm")]: {
+ padding: "6rem 2rem",
+ },
+}));
+
+export default SearchListResultHandler;
diff --git a/src/entities/search/ui/SearchLoadingSpinner.tsx b/src/entities/search/ui/SearchLoadingSpinner.tsx
new file mode 100644
index 0000000..2aba2b8
--- /dev/null
+++ b/src/entities/search/ui/SearchLoadingSpinner.tsx
@@ -0,0 +1,30 @@
+import { Box, styled } from "@mui/material";
+import type { JSX } from "react";
+
+import LoadingSpinner from "@shared/ui/loading-spinner/LoadingSpinner";
+
+const SearchLoadingSpinner = (): JSX.Element => {
+ return (
+
+
+
+ );
+};
+
+export default SearchLoadingSpinner;
+
+const LoadingContainer = styled(Box)(({ theme }) => ({
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "center",
+ alignItems: "center",
+ padding: "4rem 0rem",
+ minHeight: "400px",
+ [theme.breakpoints.up("sm")]: {
+ padding: "6rem 2rem",
+ },
+}));
diff --git a/src/entities/search/ui/SearchPagination.tsx b/src/entities/search/ui/SearchPagination.tsx
new file mode 100644
index 0000000..276a58d
--- /dev/null
+++ b/src/entities/search/ui/SearchPagination.tsx
@@ -0,0 +1,35 @@
+import { Box } from "@mui/material";
+import type { JSX } from "react";
+
+import Pagination from "@shared/ui/pagination/Pagination";
+
+interface SearchPaginationProps {
+ totalPages: number;
+ currentPage: number;
+ onPageChange: (page: number) => void;
+ disabled?: boolean;
+}
+
+const SearchPagination = ({
+ totalPages,
+ currentPage,
+ onPageChange,
+ disabled = false,
+}: SearchPaginationProps): JSX.Element | null => {
+ if (totalPages <= 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default SearchPagination;
diff --git a/src/entities/search/ui/project-search-input/ProjectSearchSelectBox.tsx b/src/entities/search/ui/SearchSelectBox.tsx
similarity index 77%
rename from src/entities/search/ui/project-search-input/ProjectSearchSelectBox.tsx
rename to src/entities/search/ui/SearchSelectBox.tsx
index f181c5f..5ceb238 100644
--- a/src/entities/search/ui/project-search-input/ProjectSearchSelectBox.tsx
+++ b/src/entities/search/ui/SearchSelectBox.tsx
@@ -2,16 +2,16 @@ import { Select, InputLabel, FormControl, MenuItem } from "@mui/material";
import type { JSX } from "react";
import { memo } from "react";
-import type { SelectFieldConfig } from "@entities/search/model/selectFieldConfigs";
+import type { SelectFieldConfig } from "@entities/search/model/searchFormConfig";
-interface ProjectSearchSelectBoxProps {
+interface SearchSelectBoxProps {
config: SelectFieldConfig;
value: string;
onChange: (value: string) => void;
}
-const ProjectSearchSelectBox = memo(
- ({ config, value, onChange }: ProjectSearchSelectBoxProps): JSX.Element => {
+const SearchSelectBox = memo(
+ ({ config, value, onChange }: SearchSelectBoxProps): JSX.Element => {
return (
{config.label}
@@ -31,4 +31,4 @@ const ProjectSearchSelectBox = memo(
}
);
-export default ProjectSearchSelectBox;
+export default SearchSelectBox;
diff --git a/src/entities/search/ui/SearchStatusField.tsx b/src/entities/search/ui/SearchStatusField.tsx
new file mode 100644
index 0000000..d0d8827
--- /dev/null
+++ b/src/entities/search/ui/SearchStatusField.tsx
@@ -0,0 +1,45 @@
+import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
+import type { JSX } from "react";
+import { memo } from "react";
+
+import {
+ DROPDOWN_DEFAULTS,
+ SEARCH_FORM_LABELS,
+} from "@entities/search/model/searchConstants";
+
+import { RecruitmentStatus } from "@shared/types/project";
+
+interface SearchStatusFieldProps {
+ value: RecruitmentStatus | "all";
+ onChange: (status: RecruitmentStatus | "all") => void;
+}
+
+const SearchStatusField = memo(
+ ({ value, onChange }: SearchStatusFieldProps): JSX.Element => {
+ return (
+
+ {SEARCH_FORM_LABELS.STATUS}
+
+
+ );
+ }
+);
+
+export default SearchStatusField;
diff --git a/src/entities/search/ui/SelectBox.tsx b/src/entities/search/ui/SelectBox.tsx
new file mode 100644
index 0000000..c7cce3c
--- /dev/null
+++ b/src/entities/search/ui/SelectBox.tsx
@@ -0,0 +1,21 @@
+import { memo } from "react";
+import type { JSX } from "react";
+
+import type { SelectFieldConfig } from "@entities/search/model/searchFormConfig";
+import SearchSelectBox from "@entities/search/ui/SearchSelectBox";
+
+interface SelectBoxProps {
+ config: SelectFieldConfig;
+ value: string;
+ onChange: (value: string) => void;
+}
+
+const SelectBox = memo(
+ ({ config, value, onChange }: SelectBoxProps): JSX.Element => {
+ return (
+
+ );
+ }
+);
+
+export default SelectBox;
diff --git a/src/entities/search/ui/project-search-input/StatusSelectField.tsx b/src/entities/search/ui/project-search-input/StatusSelectField.tsx
deleted file mode 100644
index 2ac06e1..0000000
--- a/src/entities/search/ui/project-search-input/StatusSelectField.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
-import type { JSX } from "react";
-
-import {
- DROPDOWN_DEFAULTS,
- SEARCH_FORM_LABELS,
-} from "@entities/search/model/searchOptions";
-
-import { RecruitmentStatus } from "@shared/types/project";
-
-interface StatusSelectFieldProps {
- value: RecruitmentStatus | "all";
- onChange: (status: RecruitmentStatus | "all") => void;
-}
-
-const StatusSelectField = ({
- value,
- onChange,
-}: StatusSelectFieldProps): JSX.Element => {
- return (
-
- {SEARCH_FORM_LABELS.STATUS}
-
-
- );
-};
-
-export default StatusSelectField;
diff --git a/src/features/auth/ui/LoginButton.tsx b/src/features/auth/ui/LoginButton.tsx
index 89ed306..ce40ae2 100644
--- a/src/features/auth/ui/LoginButton.tsx
+++ b/src/features/auth/ui/LoginButton.tsx
@@ -6,7 +6,7 @@ const LoginButton = (): JSX.Element => {
const navigate = useNavigate();
return (
-