Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
5 changes: 1 addition & 4 deletions src/api/fetch/post/types/PostItemType.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CategoryType } from "@/types";
import { CategoryType, ItemStatus, PostType } from "@/types";

export interface GetListResponse {
isSuccess: boolean;
Expand All @@ -19,6 +19,3 @@ export interface PostItem {
favoriteCount: number;
createdAt: string;
}

export type ItemStatus = "SEARCHING" | "FOUND";
export type PostType = "LOST" | "FOUND";
24 changes: 4 additions & 20 deletions src/app/(route)/list/_components/DefaultList/DefaultList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetStateAction<string>>; icon: IconName }[];
}

const DefaultList = ({ searchUpdateQuery }: DefaultListProps) => {
Expand All @@ -29,23 +29,7 @@ const DefaultList = ({ searchUpdateQuery }: DefaultListProps) => {
onValueChange={(key) => searchUpdateQuery("type", key)}
/>

<section aria-label="필터 영역" className="flex h-[67px] w-full items-center gap-2 px-5">
<Filter
ariaLabel="지역 선택 필터 버튼"
children={"지역 선택"}
onSelected={false}
icon={{ name: "Location", size: 16 }}
onClick={() => searchUpdateQuery("search", "region")}
/>
{/* TODO(형준): UI 깨짐 현상으로 인한 주석처리 */}
{/* {dropdowns.map(({ value, setValue, icon }, idx) => (
<Dropdown key={idx} options={[]} onSelect={setValue} className="gap-[4px]">
{idx === 0 && <Icon name={icon} size={16} />}
<span className="text-[16px] font-semibold text-[#525252]">{value}</span>
{idx !== 0 && <Icon name="ArrowDown" size={12} />}
</Dropdown>
))} */}
</section>
<FilterSection />

<section aria-label="게시글 목록" className="w-full">
{data?.result?.map((item) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { FilterTab } from "./types";
import { CategoryFilterValue, SortFilterValue, StatusFilterValue } from "./types";

export const tabs: { label: string; value: FilterTab }[] = [
{ label: "지역", value: "region" },
{ label: "카테고리", value: "category" },
{ label: "정렬", value: "sort" },
{ label: "찾음여부", value: "status" },
];

export const categories: { label: string; value: CategoryFilterValue }[] = [
{ label: "전체", value: "" },
{ label: "전자기기", value: "ELECTRONICS" },
{ label: "지갑", value: "WALLET" },
{ label: "신분증", value: "ID_CARD" },
{ label: "귀금속", value: "JEWELRY" },
{ label: "가방", value: "BAG" },
{ label: "카드", value: "CARD" },
{ label: "기타", value: "ETC" },
];

export const sort: { label: string; value: SortFilterValue }[] = [
{ label: "최신순", value: "LATEST" },
{ label: "오래된 순", value: "OLDEST" },
{ label: "즐겨찾기 많은 순", value: "MOST_FAVORITE" },
{ label: "조회수 많은 순", value: "MOST_VIEWS" },
];

export const status: { label: string; value: StatusFilterValue }[] = [
{ label: "전체", value: "" },
{ label: "찾는중", value: "SEARCHING" },
{ label: "찾았음", value: "FOUND" },
];
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<FiltersState>>;
}

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 (
<PopupLayout isOpen={isOpen} onClose={() => setIsOpen(false)} className="min-h-[530px] py-10">
<div className="w-full gap-6 flex-col-center">
<h2 className="text-h2-medium text-layout-header-default">필터</h2>

<section role="tablist" className="w-full flex-center">
{tabs.map((tab) => {
const isSelected = selectedTab === tab.value;

return (
<button
key={tab.value}
role="tab"
aria-selected={isSelected}
className={cn(
"min-h-[60px] flex-1 text-[20px] font-semibold",
// TODO(지권): 디자인 토큰 변경
isSelected ? "border-b-2 border-[#1EB87B] text-[#1EB87B]" : "text-[#ADADAD]"
)}
onClick={() => setSelectedTab(tab.value)}
>
{tab.label}
</button>
);
})}
</section>

{selectedTab === "region" && (
<div className="relative w-full">
<Icon
name="Search"
size={16}
className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
className="w-full rounded-full px-5 py-[10px] pl-10 bg-fill-neutral-subtle-default"
placeholder="검색어를 입력하세요"
value={filters.region}
onChange={(e) => setFilters((prev) => ({ ...prev, region: e.target.value }))}
/>
<button
type="button"
onClick={() => setFilters((prev) => ({ ...prev, region: "" }))}
className="absolute right-3 top-1/2 -translate-y-1/2"
aria-label="지역 검색어 지우기"
>
<Icon name="Delete" size={16} className="text-gray-400" />
</button>
</div>
)}

{selectedTab === "category" && (
<div className="flex w-full flex-wrap gap-2">
{categories.map((category) => (
<ChipButton
key={category.value || "all"}
label={category.label}
value={category.value}
selected={filters.category === category.value}
onSelect={() => setFilters((prev) => ({ ...prev, category: category.value }))}
/>
))}
</div>
)}

{selectedTab === "sort" && (
<div className="flex w-full flex-wrap gap-2">
{sort.map((sortItem, index) => (
<ChipButton
key={index}
label={sortItem.label}
value={sortItem.value}
selected={filters.sort === sortItem.value}
onSelect={() => setFilters((prev) => ({ ...prev, sort: sortItem.value }))}
/>
))}
</div>
)}

{selectedTab === "status" && (
<div className="flex w-full flex-wrap gap-2">
{status.map((statusItem, index) => (
<ChipButton
key={index}
label={statusItem.label}
value={statusItem.value}
selected={filters.status === statusItem.value}
onSelect={() => setFilters((prev) => ({ ...prev, status: statusItem.value }))}
/>
))}
</div>
)}
</div>

<div className="h-[230px] w-full" />

<Button className="w-full" onClick={handleApply}>
적용하기
</Button>
</PopupLayout>
);
};

export default FilterBottomSheet;

const ChipButton = ({
label,
value,
selected,
onSelect,
}: {
label: string;
value: string;
selected: boolean;
onSelect: (value: string) => void;
}) => {
return (
<button
type="button"
onClick={() => onSelect(value)}
className={cn(
"rounded-full px-[18px] py-2 text-body1-semibold",
selected
? "text-white bg-fill-neutralInversed-normal-enteredSelected"
: "text-neutralInversed-normal-default bg-fill-neutralInversed-normal-default"
)}
aria-pressed={selected}
>
{label}
</button>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { CategoryFilterValue, SortFilterValue, StatusFilterValue } from "./types";

export const CATEGORY_LABEL_MAP: Partial<Record<CategoryFilterValue, string>> = {
"": "카테고리",
ELECTRONICS: "전자기기",
WALLET: "지갑",
ID_CARD: "신분증",
JEWELRY: "귀금속",
BAG: "가방",
CARD: "카드",
ETC: "기타",
};

export const SORT_LABEL_MAP: Record<SortFilterValue, string> = {
LATEST: "최신순",
OLDEST: "오래된 순",
MOST_FAVORITE: "즐겨찾기 많은 순",
MOST_VIEWS: "조회수 많은 순",
};

export const STATUS_LABEL_MAP: Record<StatusFilterValue, string> = {
"": "전체",
SEARCHING: "찾는중",
FOUND: "찾음",
};
Original file line number Diff line number Diff line change
@@ -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<CategoryFilterValue, string> = {
"": "",
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<SortFilterValue, string> = {
LATEST: "latest",
OLDEST: "oldest",
MOST_FAVORITE: "mostFavorite",
MOST_VIEWS: "mostViews",
};

return map[sort];
};

const statusToQueryValue = (status: StatusFilterValue) => {
if (!status) return "";

const map: Record<StatusFilterValue, string> = {
"": "",
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();
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { CategoryType, ItemStatus } from "@/types";

// 필터 타입
export type FilterTab = "region" | "category" | "sort" | "status";

export type CategoryFilterValue = "" | CategoryType;

export type SortFilterValue = "LATEST" | "OLDEST" | "MOST_FAVORITE" | "MOST_VIEWS"; // 임시 type API 수정 후 변경

export type StatusFilterValue = "" | ItemStatus;
Loading