Skip to content

Commit 226ef12

Browse files
authored
Merge pull request #154 from YAPP-Github/feature/PRODUCT-275
feat: 위치, 분위기, 실용도 필터 생성 (#153)
2 parents 767f80c + 8105807 commit 226ef12

File tree

12 files changed

+290
-11
lines changed

12 files changed

+290
-11
lines changed

src/app/(cheer)/cheer/_components/CheerCard/CheerCard.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,22 @@ import { CHEER_CARD_CONSTANTS, SLIDER_SETTINGS } from "../../constants";
1818
import { useExpandableText } from "../../hooks";
1919
import * as styles from "./CheerCard.css";
2020

21-
export const CheerCard = ({ category }: { category: string }) => {
21+
export const CheerCard = ({
22+
category,
23+
location,
24+
tag,
25+
}: {
26+
category: string;
27+
location: string[];
28+
tag: string[];
29+
}) => {
2230
const { data } = useQuery(
23-
cheerListQueryOptions({ size: CHEER_CARD_CONSTANTS.DEFAULT_SIZE, category })
31+
cheerListQueryOptions({
32+
size: CHEER_CARD_CONSTANTS.DEFAULT_SIZE,
33+
category,
34+
location,
35+
tag,
36+
})
2437
);
2538

2639
if (!data?.cheers || data.cheers.length === 0) {

src/app/(cheer)/cheer/page.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { ChipFilter, useChipFilter } from "@/app/_shared/ChipFilter";
34
import { Bleed } from "@/components/ui/Bleed";
45
import { FoodCategories } from "@/components/ui/FoodCategory";
56
import { Spacer } from "@/components/ui/Spacer";
@@ -13,6 +14,8 @@ export default function CheerPage() {
1314
const { categories, selectedCategory, handleSelectCategory } =
1415
useFoodCategory("/cheer");
1516

17+
const { selectedFilters } = useChipFilter();
18+
1619
return (
1720
<>
1821
<MyCheerCard />
@@ -27,12 +30,19 @@ export default function CheerPage() {
2730
/>
2831
</Bleed>
2932

30-
<div>서울 전체, 분위기, 실용도 chip이 들어갈 자리</div>
33+
<ChipFilter />
3134

3235
<Spacer size={16} />
3336

3437
<Bleed inline={20}>
35-
<CheerCard category={selectedCategory.name} />
38+
<CheerCard
39+
category={selectedCategory.name}
40+
location={selectedFilters.locations}
41+
tag={[
42+
...selectedFilters.atmosphereTags,
43+
...selectedFilters.utilityTags,
44+
]}
45+
/>
3646
</Bleed>
3747
<RegisterFloatingButton />
3848
</>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { style } from "@vanilla-extract/css";
2+
3+
export const container = style({
4+
overflowX: "auto",
5+
6+
selectors: {
7+
"&::-webkit-scrollbar": {
8+
display: "none",
9+
},
10+
},
11+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useState } from "react";
2+
3+
import { FilterBottomSheet } from "@/app/_shared/FilterBottomSheet";
4+
import ChevronDownIcon from "@/assets/chevron-down.svg";
5+
import { Chip } from "@/components/ui/Chip";
6+
import { HStack } from "@/components/ui/Stack";
7+
8+
import * as styles from "./ChipFilter.css";
9+
import { type FilterTabType } from "./chipFilter.types";
10+
import { getChipDisplayText } from "./chipFilterUtils";
11+
import { useChipFilter } from "./useChipFilter";
12+
13+
/**
14+
* ChipFilter 컴포넌트
15+
*
16+
* @description
17+
* 지역, 분위기, 실용도 필터를 선택할 수 있는 Chip 버튼들을 제공합니다.
18+
* 각 Chip을 클릭하면 FilterBottomSheet가 열리고, 선택된 필터에 따라 Chip의 텍스트가 동적으로 변경됩니다.
19+
*
20+
* @features
21+
* - 3개의 필터 타입: 지역(location), 분위기(mood), 실용도(utility)
22+
* - 선택된 필터가 있으면 Chip이 활성화 상태로 표시
23+
* - 선택된 필터 개수에 따라 "외 N개" 형태로 표시
24+
* - FilterBottomSheet와 연동하여 필터 선택 UI 제공
25+
*
26+
*/
27+
export const ChipFilter = () => {
28+
const { selectedFilters, handleFilterApply } = useChipFilter();
29+
const [isFilterOpen, setIsFilterOpen] = useState(false);
30+
const [activeTab, setActiveTab] = useState<FilterTabType>("location");
31+
32+
const handleChipClick = (tab: FilterTabType) => {
33+
setActiveTab(tab);
34+
setIsFilterOpen(true);
35+
};
36+
37+
const filterConfigs = [
38+
{
39+
type: "location" as const,
40+
getActiveState: () => selectedFilters.locations.length > 0,
41+
},
42+
{
43+
type: "mood" as const,
44+
getActiveState: () => selectedFilters.atmosphereTags.length > 0,
45+
},
46+
{
47+
type: "utility" as const,
48+
getActiveState: () => selectedFilters.utilityTags.length > 0,
49+
},
50+
];
51+
52+
return (
53+
<>
54+
<HStack gap={8} className={styles.container}>
55+
{filterConfigs.map(({ type, getActiveState }) => (
56+
<Chip
57+
key={type}
58+
active={getActiveState()}
59+
onClick={() => handleChipClick(type)}
60+
>
61+
{getChipDisplayText(type, selectedFilters)}
62+
<ChevronDownIcon width={16} height={16} />
63+
</Chip>
64+
))}
65+
</HStack>
66+
67+
<FilterBottomSheet
68+
open={isFilterOpen}
69+
onOpenChange={setIsFilterOpen}
70+
onApply={handleFilterApply}
71+
defaultTab={activeTab}
72+
defaultValues={selectedFilters}
73+
/>
74+
</>
75+
);
76+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type FilterTabType = "location" | "mood" | "utility";
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { type FilterValues } from "@/app/_shared/FilterBottomSheet";
2+
import { LOCATIONS } from "@/constants/location.constants";
3+
import { ALL_TAGS } from "@/constants/tag.constants";
4+
5+
import { type FilterTabType } from "./chipFilter.types";
6+
7+
/**
8+
* 지역별 라벨 매핑 (value -> districts[0] 또는 label)
9+
*/
10+
const LOCATION_LABEL: Record<string, string> = Object.fromEntries(
11+
LOCATIONS.map(location => [
12+
location.value,
13+
location.districts[0] ?? location.label,
14+
])
15+
);
16+
17+
/**
18+
* 태그별 라벨 매핑 (name -> label)
19+
*/
20+
const TAG_LABEL: Record<string, string> = Object.fromEntries(
21+
ALL_TAGS.map(tag => [tag.name, tag.label])
22+
);
23+
24+
/**
25+
* 각 필터 타입별 기본 표시 텍스트
26+
*/
27+
const DEFAULT_TEXT: Record<FilterTabType, string> = {
28+
location: "서울 전체",
29+
mood: "분위기",
30+
utility: "실용도",
31+
} as const;
32+
33+
/**
34+
* 필터 타입에 따라 해당하는 값들을 선택
35+
*/
36+
const pickValues = (type: FilterTabType, selected: FilterValues): string[] => {
37+
switch (type) {
38+
case "location":
39+
return selected.locations;
40+
case "mood":
41+
return selected.atmosphereTags;
42+
case "utility":
43+
return selected.utilityTags;
44+
}
45+
};
46+
47+
/**
48+
* 필터 값에 대한 실제 표시 라벨을 반환
49+
*/
50+
const labelFor = (type: FilterTabType, value: string): string => {
51+
if (type === "location") {
52+
return LOCATION_LABEL[value] ?? value;
53+
}
54+
55+
return TAG_LABEL[value] ?? value;
56+
};
57+
58+
/**
59+
* Chip에 표시할 텍스트를 생성
60+
* 선택된 필터 개수에 따라 "외 N개" 형태로 표시
61+
*/
62+
export const getChipDisplayText = (
63+
type: FilterTabType,
64+
selected: FilterValues
65+
): string => {
66+
const values = pickValues(type, selected);
67+
if (values.length === 0) {
68+
return DEFAULT_TEXT[type];
69+
}
70+
71+
const firstLabel = labelFor(type, values[0] ?? "");
72+
return values.length === 1
73+
? firstLabel
74+
: `${firstLabel}${values.length - 1}개`;
75+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { ChipFilter } from "./ChipFilter";
2+
export * from "./chipFilter.types";
3+
export * from "./chipFilterUtils";
4+
export { useChipFilter } from "./useChipFilter";
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { usePathname, useRouter, useSearchParams } from "next/navigation";
2+
3+
import { type FilterValues } from "@/app/_shared/FilterBottomSheet";
4+
import { ATMOSPHERE_TAGS, UTILITY_TAGS } from "@/constants/tag.constants";
5+
6+
/**
7+
* useChipFilter 훅
8+
*
9+
* @description
10+
* ChipFilter 컴포넌트에서 사용하는 필터 상태를 관리하고 URL 파라미터와 동기화하는 훅입니다.
11+
* 지역, 분위기, 실용도 필터의 선택 상태를 URL 쿼리 파라미터로 관리하며,
12+
* 필터 적용 시 URL을 업데이트하고 다른 컴포넌트에서 필터 상태를 읽을 수 있도록 합니다.
13+
*
14+
* @returns {FilterValues} selectedFilters - 현재 선택된 모든 필터 값들
15+
* @returns {Function} handleFilterApply - 필터 적용 시 호출되는 함수
16+
*
17+
*/
18+
export const useChipFilter = () => {
19+
const router = useRouter();
20+
const searchParams = useSearchParams();
21+
const pathname = usePathname();
22+
23+
const selectedFilters: FilterValues = {
24+
locations: searchParams.get("location")?.split(",").filter(Boolean) || [],
25+
atmosphereTags:
26+
searchParams
27+
.get("tag")
28+
?.split(",")
29+
.filter(tag =>
30+
ATMOSPHERE_TAGS.some(atmosphereTag => atmosphereTag.name === tag)
31+
) || [],
32+
utilityTags:
33+
searchParams
34+
.get("tag")
35+
?.split(",")
36+
.filter(tag =>
37+
UTILITY_TAGS.some(utilityTag => utilityTag.name === tag)
38+
) || [],
39+
};
40+
41+
const handleFilterApply = (filters: FilterValues) => {
42+
const params = new URLSearchParams(searchParams.toString());
43+
44+
if (filters.locations.length > 0) {
45+
params.set("location", filters.locations.join(","));
46+
} else {
47+
params.delete("location");
48+
}
49+
50+
const allTags = [...filters.atmosphereTags, ...filters.utilityTags];
51+
if (allTags.length > 0) {
52+
params.set("tag", allTags.join(","));
53+
} else {
54+
params.delete("tag");
55+
}
56+
57+
const newUrl = params.toString()
58+
? `${pathname}?${params.toString()}`
59+
: pathname;
60+
router.replace(newUrl);
61+
};
62+
63+
return {
64+
selectedFilters,
65+
handleFilterApply,
66+
};
67+
};

src/app/_shared/FilterBottomSheet/FilterBottomSheet.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Tabs } from "@/components/ui/Tabs";
1313
import { LOCATIONS } from "@/constants/location.constants";
1414
import { ATMOSPHERE_TAGS, UTILITY_TAGS } from "@/constants/tag.constants";
1515

16+
import { type FilterTabType } from "../ChipFilter";
1617
import * as styles from "./FilterBottomSheet.css";
1718

1819
export type FilterValues = {
@@ -34,6 +35,10 @@ export type FilterBottomSheetProps = {
3435
* 실시간 필터 변경 시 호출되는 콜백 (제공되면 자동으로 실시간 업데이트 활성화)
3536
*/
3637
onChange?: (filters: FilterValues) => void;
38+
/**
39+
* 기본적으로 열릴 탭 (location, mood, utility)
40+
*/
41+
defaultTab?: FilterTabType;
3742
} & Omit<BottomSheetRootProps, "children">;
3843

3944
/**
@@ -109,6 +114,7 @@ export const FilterBottomSheet = ({
109114
onApply,
110115
defaultValues,
111116
onChange,
117+
defaultTab,
112118
}: FilterBottomSheetProps) => {
113119
const [selectedLocations, setSelectedLocations] = useState<string[]>(
114120
defaultValues?.locations ?? []
@@ -174,7 +180,7 @@ export const FilterBottomSheet = ({
174180
<BottomSheet.Title>
175181
<></>
176182
</BottomSheet.Title>
177-
<Tabs.Root defaultValue='location'>
183+
<Tabs.Root defaultValue={defaultTab ?? "location"}>
178184
<Tabs.List>
179185
<Tabs.Trigger value='location'>지역</Tabs.Trigger>
180186
<Tabs.Trigger value='mood'>분위기</Tabs.Trigger>

src/assets/chevron-down.svg

Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)