diff --git a/src/api/fetch/post/types/PostItemType.ts b/src/api/fetch/post/types/PostItemType.ts index 8368b5ea..6c0811f1 100644 --- a/src/api/fetch/post/types/PostItemType.ts +++ b/src/api/fetch/post/types/PostItemType.ts @@ -1,4 +1,4 @@ -import { CategoryType } from "@/types"; +import { CategoryType, ItemStatus, PostType } from "@/types"; export interface GetListResponse { isSuccess: boolean; @@ -19,6 +19,3 @@ export interface PostItem { favoriteCount: number; createdAt: string; } - -export type ItemStatus = "SEARCHING" | "FOUND"; -export type PostType = "LOST" | "FOUND"; diff --git a/src/app/(route)/list/_components/DefaultList/DefaultList.tsx b/src/app/(route)/list/_components/DefaultList/DefaultList.tsx index 70619cc5..5642d13c 100644 --- a/src/app/(route)/list/_components/DefaultList/DefaultList.tsx +++ b/src/app/(route)/list/_components/DefaultList/DefaultList.tsx @@ -2,15 +2,15 @@ import { useSearchParams } from "next/navigation"; import { useGetPost } from "@/api/fetch/post"; -import { Filter, Tab } from "@/components"; -import ListItem from "../ListItem/ListItem"; +import { Tab } from "@/components"; import { TABS } from "../../_constants/TABS"; +import ListItem from "../ListItem/ListItem"; +import FilterSection from "../_internal/FilterSection/FilterSection"; type PostType = "LOST" | "FOUND"; interface DefaultListProps { searchUpdateQuery: (key: string, value?: string) => void; - // dropdowns?: { value: string; setValue: Dispatch>; icon: IconName }[]; } const DefaultList = ({ searchUpdateQuery }: DefaultListProps) => { @@ -29,23 +29,7 @@ const DefaultList = ({ searchUpdateQuery }: DefaultListProps) => { onValueChange={(key) => searchUpdateQuery("type", key)} /> -
- searchUpdateQuery("search", "region")} - /> - {/* TODO(형준): UI 깨짐 현상으로 인한 주석처리 */} - {/* {dropdowns.map(({ value, setValue, icon }, idx) => ( - - {idx === 0 && } - {value} - {idx !== 0 && } - - ))} */} -
+
{data?.result?.map((item) => ( diff --git a/src/app/(route)/list/_components/_internal/FilterBottomSheet/CONSTANTS.ts b/src/app/(route)/list/_components/_internal/FilterBottomSheet/CONSTANTS.ts new file mode 100644 index 00000000..261c92d0 --- /dev/null +++ b/src/app/(route)/list/_components/_internal/FilterBottomSheet/CONSTANTS.ts @@ -0,0 +1,30 @@ +export const tabs = [ + { label: "지역", value: "region" }, + { label: "카테고리", value: "category" }, + { label: "정렬", value: "sort" }, + { label: "찾음여부", value: "status" }, +] as const; + +export const categories = [ + { label: "전체", value: "" }, + { label: "전자기기", value: "ELECTRONICS" }, + { label: "지갑", value: "WALLET" }, + { label: "신분증", value: "ID_CARD" }, + { label: "귀금속", value: "JEWELRY" }, + { label: "가방", value: "BAG" }, + { label: "카드", value: "CARD" }, + { label: "기타", value: "ETC" }, +] as const; + +export const sort = [ + { label: "최신순", value: "" }, + { label: "오래된 순", value: "OLDEST" }, + { label: "즐겨찾기 많은 순", value: "MOST_FAVORITED" }, + { label: "조회수 많은 순", value: "MOST_VIWED" }, +] as const; + +export const status = [ + { label: "전체", value: "" }, + { label: "찾는중", value: "SEARCHING" }, + { label: "찾았음", value: "FOUND" }, +] as const; diff --git a/src/app/(route)/list/_components/_internal/FilterBottomSheet/FilterBottomSheet.tsx b/src/app/(route)/list/_components/_internal/FilterBottomSheet/FilterBottomSheet.tsx new file mode 100644 index 00000000..3c01c1d8 --- /dev/null +++ b/src/app/(route)/list/_components/_internal/FilterBottomSheet/FilterBottomSheet.tsx @@ -0,0 +1,172 @@ +import { Dispatch, SetStateAction } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { cn } from "@/utils"; +import { Button, Icon, PopupLayout } from "@/components"; +import { FilterTab } from "./types"; +import { tabs, categories, sort, status } from "./CONSTANTS"; +import { applyFiltersToUrl } from "./applyFiltersToUrl"; +import { FiltersState } from "../FilterSection/filtersStateType"; + +interface FilterBottomSheetProps { + isOpen: boolean; + setIsOpen: (value: boolean) => void; + selectedTab: FilterTab; + setSelectedTab: (tab: FilterTab) => void; + filters: FiltersState; + setFilters: Dispatch>; +} + +const FilterBottomSheet = ({ + isOpen, + setIsOpen, + selectedTab, + setSelectedTab, + filters, + setFilters, +}: FilterBottomSheetProps) => { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const handleApply = () => { + const qs = applyFiltersToUrl({ + filters, + searchParams: new URLSearchParams(searchParams.toString()), + }); + + router.replace(qs ? `${pathname}?${qs}` : pathname); + setIsOpen(false); + }; + + return ( + setIsOpen(false)} className="min-h-[530px] py-10"> +
+

필터

+ +
+ {tabs.map((tab) => { + const isSelected = selectedTab === tab.value; + + return ( + + ); + })} +
+ + {selectedTab === "region" && ( +
+ + setFilters((prev) => ({ ...prev, region: e.target.value }))} + /> + +
+ )} + + {selectedTab === "category" && ( +
+ {categories.map((category) => ( + setFilters((prev) => ({ ...prev, category: category.value }))} + /> + ))} +
+ )} + + {selectedTab === "sort" && ( +
+ {sort.map((sortItem, index) => ( + setFilters((prev) => ({ ...prev, sort: sortItem.value }))} + /> + ))} +
+ )} + + {selectedTab === "status" && ( +
+ {status.map((statusItem, index) => ( + setFilters((prev) => ({ ...prev, status: statusItem.value }))} + /> + ))} +
+ )} +
+ +
+ + + + ); +}; + +export default FilterBottomSheet; + +const ChipButton = ({ + label, + value, + selected, + onSelect, +}: { + label: string; + value: string; + selected: boolean; + onSelect: (value: string) => void; +}) => { + return ( + + ); +}; diff --git a/src/app/(route)/list/_components/_internal/FilterBottomSheet/LABELS.ts b/src/app/(route)/list/_components/_internal/FilterBottomSheet/LABELS.ts new file mode 100644 index 00000000..227f402b --- /dev/null +++ b/src/app/(route)/list/_components/_internal/FilterBottomSheet/LABELS.ts @@ -0,0 +1,25 @@ +import { CategoryFilterValue, SortFilterValue, StatusFilterValue } from "./types"; + +export const CATEGORY_LABEL_MAP: Partial> = { + "": "카테고리", + ELECTRONICS: "전자기기", + WALLET: "지갑", + ID_CARD: "신분증", + JEWELRY: "귀금속", + BAG: "가방", + CARD: "카드", + ETC: "기타", +}; + +export const SORT_LABEL_MAP: Record = { + "": "최신순", + OLDEST: "오래된 순", + MOST_FAVORITED: "즐겨찾기 많은 순", + MOST_VIWED: "조회수 많은 순", +}; + +export const STATUS_LABEL_MAP: Record = { + "": "전체", + SEARCHING: "찾는중", + FOUND: "찾음", +}; diff --git a/src/app/(route)/list/_components/_internal/FilterBottomSheet/applyFiltersToUrl.ts b/src/app/(route)/list/_components/_internal/FilterBottomSheet/applyFiltersToUrl.ts new file mode 100644 index 00000000..d6edb22c --- /dev/null +++ b/src/app/(route)/list/_components/_internal/FilterBottomSheet/applyFiltersToUrl.ts @@ -0,0 +1,65 @@ +import { FiltersState } from "../FilterSection/filtersStateType"; +import { CategoryFilterValue, SortFilterValue, StatusFilterValue } from "./types"; + +const categoryToQueryValue = (category: CategoryFilterValue) => { + if (!category) return ""; + + const map: Record = { + "": "", + ELECTRONICS: "electronics", + WALLET: "wallet", + ID_CARD: "id-card", + JEWELRY: "jewelry", + BAG: "bag", + CARD: "card", + ETC: "etc", + }; + + return map[category]; +}; + +const sortToQueryValue = (sort: SortFilterValue) => { + if (!sort) return ""; + + const map: Record = { + "": "", + OLDEST: "oldest", + MOST_FAVORITED: "mostFavorite", + MOST_VIWED: "mostViewed", + }; + + return map[sort]; +}; + +const statusToQueryValue = (status: StatusFilterValue) => { + if (!status) return ""; + + const map: Record = { + "": "", + FOUND: "found", + SEARCHING: "searching", + }; + + return map[status]; +}; + +type ApplyFiltersToUrlProps = { + filters: FiltersState; + searchParams: URLSearchParams; +}; + +export const applyFiltersToUrl = ({ filters, searchParams }: ApplyFiltersToUrlProps): string => { + const params = new URLSearchParams(searchParams.toString()); + + const upsert = (key: string, value: string) => { + if (!value) params.delete(key); + else params.set(key, value); + }; + + upsert("region", filters.region); + upsert("category", categoryToQueryValue(filters.category)); + upsert("sort", sortToQueryValue(filters.sort)); + upsert("status", statusToQueryValue(filters.status)); + + return params.toString(); +}; diff --git a/src/app/(route)/list/_components/_internal/FilterBottomSheet/types.ts b/src/app/(route)/list/_components/_internal/FilterBottomSheet/types.ts new file mode 100644 index 00000000..f258e0c3 --- /dev/null +++ b/src/app/(route)/list/_components/_internal/FilterBottomSheet/types.ts @@ -0,0 +1,10 @@ +import { CategoryType, ItemStatus } from "@/types"; + +// 필터 타입 +export type FilterTab = "region" | "category" | "sort" | "status"; + +export type CategoryFilterValue = "" | CategoryType; + +export type SortFilterValue = "" | "OLDEST" | "MOST_FAVORITED" | "MOST_VIWED"; + +export type StatusFilterValue = "" | ItemStatus; diff --git a/src/app/(route)/list/_components/_internal/FilterSection/FilterSection.tsx b/src/app/(route)/list/_components/_internal/FilterSection/FilterSection.tsx new file mode 100644 index 00000000..230456f5 --- /dev/null +++ b/src/app/(route)/list/_components/_internal/FilterSection/FilterSection.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { Filter } from "@/components"; +import FilterBottomSheet from "../FilterBottomSheet/FilterBottomSheet"; +import { CATEGORY_LABEL_MAP, SORT_LABEL_MAP, STATUS_LABEL_MAP } from "../FilterBottomSheet/LABELS"; +import { + CategoryFilterValue, + FilterTab, + SortFilterValue, + StatusFilterValue, +} from "../FilterBottomSheet/types"; +import { FiltersState } from "./filtersStateType"; +import { getFilterSelectedFlags } from "./getFilterSelectedFlags"; + +const FilterSection = () => { + const [filters, setFilters] = useState({ + region: "", + category: "" as CategoryFilterValue, + sort: "" as SortFilterValue, + status: "" as StatusFilterValue, + }); + + const [selectedTab, setSelectedTab] = useState("region"); + const [isOpen, setIsOpen] = useState(false); + + const openSheet = (tab: FilterTab) => { + setSelectedTab(tab); + setIsOpen(true); + }; + + const { isRegionSelected, isCategorySelected, isSortSelected, isStatusSelected } = + getFilterSelectedFlags(filters); + + const categoryLabel = CATEGORY_LABEL_MAP[filters.category as CategoryFilterValue] ?? "카테고리"; + const sortLabel = SORT_LABEL_MAP[filters.sort as SortFilterValue] ?? "최신순"; + const statusLabel = STATUS_LABEL_MAP[filters.status as StatusFilterValue] ?? "전체"; + + return ( + <> +
+ openSheet("region")} + > + {isRegionSelected ? filters.region : "지역 선택"} + + + openSheet("category")} + > + {categoryLabel} + + + openSheet("sort")} + > + {sortLabel} + + + openSheet("status")} + > + {statusLabel} + +
+ + {isOpen && ( + + )} + + ); +}; + +export default FilterSection; diff --git a/src/app/(route)/list/_components/_internal/FilterSection/filtersStateType.ts b/src/app/(route)/list/_components/_internal/FilterSection/filtersStateType.ts new file mode 100644 index 00000000..b3b22f65 --- /dev/null +++ b/src/app/(route)/list/_components/_internal/FilterSection/filtersStateType.ts @@ -0,0 +1,12 @@ +import { + CategoryFilterValue, + SortFilterValue, + StatusFilterValue, +} from "../FilterBottomSheet/types"; + +export type FiltersState = { + region: string; + category: CategoryFilterValue; + sort: SortFilterValue; + status: StatusFilterValue; +}; diff --git a/src/app/(route)/list/_components/_internal/FilterSection/getFilterSelectedFlags.ts b/src/app/(route)/list/_components/_internal/FilterSection/getFilterSelectedFlags.ts new file mode 100644 index 00000000..90a90daa --- /dev/null +++ b/src/app/(route)/list/_components/_internal/FilterSection/getFilterSelectedFlags.ts @@ -0,0 +1,15 @@ +import { FiltersState } from "./filtersStateType"; + +export const getFilterSelectedFlags = (filters: FiltersState) => { + const isRegionSelected = filters.region.trim().length > 0; + const isCategorySelected = filters.category !== ""; + const isSortSelected = filters.sort !== ""; + const isStatusSelected = filters.status !== ""; + + return { + isRegionSelected, + isCategorySelected, + isSortSelected, + isStatusSelected, + }; +}; diff --git a/src/types/CategoryType.ts b/src/types/ItemType.ts similarity index 53% rename from src/types/CategoryType.ts rename to src/types/ItemType.ts index ff9564fb..04f613d4 100644 --- a/src/types/CategoryType.ts +++ b/src/types/ItemType.ts @@ -3,8 +3,9 @@ * * 카테고리 타입 정의 * - * - 이 타입은 분실물 및 습득물 게시글의 카테고리를 나타냅니다. + * - 이 타입은 분실물 및 습득물 게시글의 타입을 표시합니다. * + * - "": 전체 * - ELECTRONIC: 전자기기 * - WALLET: 지갑 * - ID_CARD: 신분증 @@ -12,6 +13,12 @@ * - BAG: 가방 * - CARD: 카드 * - ETC: 기타 + * + * - LOST: 분실물 + * - FOUND: 습득물 + * + * - SEARCHING: 찾는중 + * - FOUND: 찾았음 */ export type CategoryType = @@ -22,3 +29,7 @@ export type CategoryType = | "BAG" | "CARD" | "ETC"; + +export type PostType = "LOST" | "FOUND"; + +export type ItemStatus = "SEARCHING" | "FOUND"; diff --git a/src/types/index.ts b/src/types/index.ts index a592734a..ec1bef5b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1,2 @@ export * from "./ToastTypes"; -export type { CategoryType } from "./CategoryType"; +export type * from "./ItemType"; diff --git a/src/utils/getItemStatusLabel/getItemStatusLabel.ts b/src/utils/getItemStatusLabel/getItemStatusLabel.ts index a8234d81..0c5481fc 100644 --- a/src/utils/getItemStatusLabel/getItemStatusLabel.ts +++ b/src/utils/getItemStatusLabel/getItemStatusLabel.ts @@ -1,4 +1,4 @@ -import { ItemStatus } from "@/api/fetch/post"; +import { ItemStatus } from "@/types"; /** * @author jikwon