Skip to content

Commit f974d85

Browse files
committed
[feat]칵테일 필터링기능
1 parent 1b4850b commit f974d85

File tree

5 files changed

+211
-37
lines changed

5 files changed

+211
-37
lines changed
Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,58 @@
1+
'use client';
12
import { getApi } from '@/app/api/config/appConfig';
23
import { Cocktail } from '../types/types';
3-
import { Dispatch, SetStateAction } from 'react';
4+
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
45

56
interface Props {
67
setData: Dispatch<SetStateAction<Cocktail[]>>;
78
setNoResults: Dispatch<React.SetStateAction<boolean>>;
89
}
910

1011
function CocktailSearch({ setData, setNoResults }: Props) {
11-
const searchApi = async (v: string) => {
12-
const keyword = v.trim();
13-
if (!keyword) {
12+
const [alcoholStrengths, setAlcoholStrengths] = useState<string[]>([]);
13+
const [cocktailTypes, setCocktailTypes] = useState<string[]>([]);
14+
const [alcoholBaseTypes, setAlcoholBaseTypes] = useState<string[]>([]);
15+
16+
const searchApi = async (v?: string) => {
17+
const keyword = v?.trim() ?? '';
18+
const body = {
19+
keyword,
20+
alcoholStrengths,
21+
cocktailTypes,
22+
alcoholBaseTypes,
23+
page: 0,
24+
size: 100,
25+
};
26+
27+
if (!keyword && !alcoholStrengths.length && !cocktailTypes.length && !alcoholBaseTypes.length) {
1428
setData([]);
15-
return;
29+
setNoResults(false);
30+
return null;
1631
}
1732

1833
const res = await fetch(`${getApi}/cocktails/search`, {
1934
method: 'POST',
2035
headers: { 'Content-Type': 'application/json' },
21-
body: JSON.stringify({ keyword }),
36+
body: JSON.stringify(body),
2237
});
2338
const json = await res.json();
39+
2440
setData(json.data);
2541
setNoResults(json.data.length === 0);
2642
};
2743

28-
return { searchApi };
44+
useEffect(() => {
45+
searchApi();
46+
}, [alcoholStrengths, cocktailTypes, alcoholBaseTypes]);
47+
48+
return {
49+
searchApi,
50+
setAlcoholBaseTypes,
51+
setAlcoholStrengths,
52+
setCocktailTypes,
53+
alcoholBaseTypes,
54+
alcoholStrengths,
55+
cocktailTypes,
56+
};
2957
}
3058
export default CocktailSearch;
Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
'use client';
2-
import Back from '@/shared/assets/icons/back_36.svg';
2+
3+
import { useSearchParams } from 'next/navigation';
34
import Link from 'next/link';
5+
import Back from '@/shared/assets/icons/back_36.svg';
6+
7+
function BackButton() {
8+
const searchParams = useSearchParams();
9+
const recipeUrl = `/recipe${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
10+
11+
// 쿼리스트링을 유지한채 뒤로 돌아가야함
412

5-
function BackBtn() {
613
return (
714
<button type="button" className="z-1" aria-label="뒤로가기">
8-
<Link href="/recipe">
15+
<Link href={recipeUrl}>
916
<Back />
1017
</Link>
1118
</button>
1219
);
1320
}
14-
export default BackBtn;
21+
22+
export default BackButton;
Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,162 @@
11
'use client';
22

33
import SelectBox from '@/shared/components/select-box/SelectBox';
4+
import { Dispatch, SetStateAction, useEffect, useMemo } from 'react';
5+
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
6+
7+
interface Props {
8+
setAlcoholBaseTypes: Dispatch<SetStateAction<string[]>>;
9+
setAlcoholStrengths: Dispatch<SetStateAction<string[]>>;
10+
setCocktailTypes: Dispatch<SetStateAction<string[]>>;
11+
}
412

513
const SELECT_OPTIONS = [
614
{
715
id: 'abv',
8-
option: ['전체', '논알콜', '약한 도수', '가벼운 도수', '중간 도수', '센 도수', '매우 센 도수'],
16+
option: [
17+
'전체',
18+
'논알콜',
19+
'약한 도수 (1~5%)',
20+
'가벼운 도수 (6~15%)',
21+
'중간 도수(16~25%)',
22+
'센 도수(26~36%)',
23+
'매우 센 도수(36%~)',
24+
],
25+
map: {
26+
전체: null,
27+
논알콜: 'NON_ALCOHOLIC',
28+
'약한 도수 (1~5%)': 'WEAK',
29+
'가벼운 도수 (6~15%)': 'LIGHT',
30+
'중간 도수(16~25%)': 'MEDIUM',
31+
'센 도수(26~36%)': 'STRONG',
32+
'매우 센 도수(36%~)': 'VERY_STRONG',
33+
},
934
title: '도수',
1035
},
1136
{
1237
id: 'base',
13-
option: ['전체', '위스키', '진', '럼', '보드카', '데킬라', '리큐르'],
38+
option: ['전체', '위스키', '브랜디', '진', '럼', '보드카', '데킬라', '리큐르', '와인', '기타'],
39+
map: {
40+
전체: null,
41+
: 'GIN',
42+
: 'RUM',
43+
보드카: 'VODKA',
44+
위스키: 'WHISKY',
45+
데킬라: 'TEQUILA',
46+
리큐르: 'LIQUEUR',
47+
브랜디: 'BRANDY',
48+
와인: 'WINE',
49+
기타: 'OTHER',
50+
},
1451
title: '베이스',
1552
},
1653
{
1754
id: 'glass',
1855
option: ['전체', '클래식', '롱', '슈터', '숏'],
56+
map: {
57+
전체: null,
58+
: 'SHORT',
59+
: 'LONG',
60+
클래식: 'CLASSIC',
61+
슈터: 'SHOOTER',
62+
},
1963
title: '글라스',
2064
},
2165
];
2266

23-
function Accordion() {
67+
function Accordion({ setAlcoholBaseTypes, setCocktailTypes, setAlcoholStrengths }: Props) {
68+
const router = useRouter();
69+
const pathname = usePathname();
70+
const searchParams = useSearchParams();
71+
72+
// 코드를 한글 라벨로 역변환하는 함수
73+
const getDisplayValue = (id: string, code: string | null): string => {
74+
if (!code) return '전체';
75+
76+
const optionGroup = SELECT_OPTIONS.find((opt) => opt.id === id);
77+
if (!optionGroup) return '전체';
78+
79+
// map 객체에서 code와 일치하는 key(한글)를 찾기
80+
const entry = Object.entries(optionGroup.map).find(([_, value]) => value === code);
81+
return entry ? entry[0] : '전체';
82+
};
83+
84+
// URL 파라미터에서 현재 선택된 값 가져오기
85+
const currentValues = useMemo(() => {
86+
return {
87+
abv: getDisplayValue('abv', searchParams.get('abv')),
88+
base: getDisplayValue('base', searchParams.get('base')),
89+
glass: getDisplayValue('glass', searchParams.get('glass')),
90+
};
91+
}, [searchParams]);
92+
93+
// URL에서 상태 동기화
94+
useEffect(() => {
95+
const abv = searchParams.get('abv');
96+
const base = searchParams.get('base');
97+
const glass = searchParams.get('glass');
98+
99+
setAlcoholStrengths(abv ? [abv] : []);
100+
setAlcoholBaseTypes(base ? [base] : []);
101+
setCocktailTypes(glass ? [glass] : []);
102+
}, [searchParams, setAlcoholStrengths, setAlcoholBaseTypes, setCocktailTypes]);
103+
104+
const handleSelect = (id: string, value: string) => {
105+
const optionGroup = SELECT_OPTIONS.find((opt) => opt.id === id);
106+
if (!optionGroup) return;
107+
108+
const code = optionGroup.map[value as keyof typeof optionGroup.map] ?? null;
109+
const arr = code ? [code] : [];
110+
111+
// 상태 즉시 업데이트
112+
switch (id) {
113+
case 'abv':
114+
setAlcoholStrengths(arr);
115+
break;
116+
case 'base':
117+
setAlcoholBaseTypes(arr);
118+
break;
119+
case 'glass':
120+
setCocktailTypes(arr);
121+
break;
122+
}
123+
124+
// URL 업데이트
125+
const params = new URLSearchParams(searchParams.toString());
126+
127+
if (code) {
128+
params.set(id, code);
129+
} else {
130+
params.delete(id);
131+
}
132+
133+
const queryString = params.toString();
134+
const newUrl = `${pathname}${queryString ? `?${queryString}` : ''}`;
135+
136+
// shallow routing으로 URL만 변경
137+
window.history.pushState({ ...window.history.state, as: newUrl, url: newUrl }, '', newUrl);
138+
};
139+
24140
return (
25141
<ul className="flex w-full gap-3">
26142
{SELECT_OPTIONS.map(({ id, option, title }) => {
143+
const currentValue = currentValues[id as keyof typeof currentValues];
144+
27145
return (
28146
<li key={id}>
29-
<SelectBox option={option} title={title} id={id} groupKey="filter" />
147+
<SelectBox
148+
option={option}
149+
title={title}
150+
id={id}
151+
groupKey="filter"
152+
value={currentValue} // 현재 선택된 값 전달
153+
onChange={(value) => handleSelect(id, value)}
154+
/>
30155
</li>
31156
);
32157
})}
33158
</ul>
34159
);
35160
}
161+
36162
export default Accordion;

src/domains/recipe/components/main/Cocktails.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,19 @@ function Cocktails() {
2828
const { inputValue, keyword, isSearching, onInputChange, noResults, setNoResults } =
2929
useSearchControl({ delay: 300, storageKey: 'cocktails_scoll_state' });
3030
const { fetchData } = RecipeFetch({ setData, lastId, setLastId, hasNextPage, setHasNextPage });
31-
const { searchApi } = CocktailSearch({ setData, setNoResults });
31+
const {
32+
searchApi,
33+
setAlcoholBaseTypes,
34+
setAlcoholStrengths,
35+
setCocktailTypes,
36+
alcoholBaseTypes,
37+
cocktailTypes,
38+
alcoholStrengths,
39+
} = CocktailSearch({
40+
setData,
41+
setNoResults,
42+
});
43+
3244
const countLabel = isSearching
3345
? hasNextPage
3446
? `검색결과 현재 ${data.length}+`
@@ -44,7 +56,7 @@ function Cocktails() {
4456
if (readyForFirstLoad) {
4557
fetchData();
4658
}
47-
}, [hasNextPage]);
59+
}, [hasNextPage, lastId]);
4860

4961
// 검색어 변경 시
5062
useEffect(() => {
@@ -58,22 +70,27 @@ function Cocktails() {
5870
setLastId(null);
5971
setHasNextPage(true);
6072
}
61-
}, [keyword, isSearching]);
73+
}, [keyword, isSearching, alcoholBaseTypes, alcoholStrengths, cocktailTypes]);
6274

6375
// 일반 fetch
6476
useEffect(() => {
65-
if (!shouldFetch) return;
66-
if (isSearching) return;
77+
if (!shouldFetch || isSearching) return;
6778
fetchData();
6879
}, [shouldFetch, isSearching]);
6980

7081
return (
7182
<section>
7283
<div className="flex flex-col-reverse items-start gap-6 md:flex-row md:justify-between md:items-center ">
73-
<Accordion />
84+
<Accordion
85+
setAlcoholBaseTypes={setAlcoholBaseTypes}
86+
setAlcoholStrengths={setAlcoholStrengths}
87+
setCocktailTypes={setCocktailTypes}
88+
/>
7489
<CocktailSearchBar value={inputValue} onChange={onInputChange} />
7590
</div>
91+
7692
<CocktailFilter cocktailsEA={countLabel} />
93+
7794
<section className="mt-5">
7895
{isSearching && noResults ? (
7996
<div>검색결과가 없습니다.</div>

src/shared/components/select-box/SelectBox.tsx

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use client';
2-
import { Ref, useMemo, useRef, useState } from 'react';
2+
import { Ref, useEffect, useMemo, useRef, useState } from 'react';
33
import Down from '@/shared/assets/icons/selectDown_24.svg';
44
import { useShallow } from 'zustand/shallow';
55
import { ID, useAccordionStore } from '@/domains/recipe/store/accordionStore';
@@ -11,22 +11,26 @@ interface Props {
1111
ref?: Ref<HTMLButtonElement | null>;
1212
option: string[];
1313
title: string;
14+
value?: string;
1415
onChange?: (value: string) => void;
1516
use?: string;
1617
}
1718

18-
// groupKey를 Props로 내릴경우 == 아코디언 없는 경우 == select박스
19-
function SelectBox({ id, groupKey, ref, option, title, onChange, use }: Props) {
19+
function SelectBox({ id, groupKey, ref, option, title, value, onChange, use }: Props) {
2020
const [isOpen, setIsOpen] = useState(false);
21-
const [select, setSelect] = useState('');
21+
const [select, setSelect] = useState(value || '');
2222
const menuRef = useRef<HTMLDivElement>(null);
2323

2424
const ingroup = !!groupKey;
25-
// groupKey가 있을경우 true
26-
// groupkey일 경우 전달받은 ID로 식별 아닐경우 title로 식별
2725

2826
const keyId = useMemo<ID>(() => id ?? title, [id, title]);
29-
// id가 없을경우 title로 키 아이디를 받음
27+
28+
// value prop이 변경되면 select state도 업데이트
29+
useEffect(() => {
30+
if (value !== undefined) {
31+
setSelect(value);
32+
}
33+
}, [value]);
3034

3135
useCloseOutside({
3236
menuRef,
@@ -39,22 +43,18 @@ function SelectBox({ id, groupKey, ref, option, title, onChange, use }: Props) {
3943
const { openId, toggleGroup, closeGroup } = useAccordionStore(
4044
useShallow((s) => ({
4145
openId: ingroup ? (s.openByGroup[groupKey] ?? null) : null,
42-
// 그룹키가 없으면 openId == null 따라서 state로 관리됨
4346
toggleGroup: s.toggle,
4447
closeGroup: s.closeGroup,
4548
}))
4649
);
4750

48-
//groupkey가 있을 떄와 없을때로 구분해서 state혹은 store로 관리
4951
const localOpen = ingroup ? openId === keyId : isOpen;
5052

51-
// 그룹일 경우 filter와 id abv | base | glass 를
5253
const toggle = () => {
5354
if (ingroup) toggleGroup(groupKey, keyId);
5455
else
5556
setIsOpen((prev) => {
5657
const next = !prev;
57-
console.log('TOGGLE BTN CLICK:', { prev, next });
5858
return next;
5959
});
6060
};
@@ -71,11 +71,6 @@ function SelectBox({ id, groupKey, ref, option, title, onChange, use }: Props) {
7171
close();
7272
};
7373

74-
useCloseOutside({
75-
menuRef,
76-
onClose: close,
77-
});
78-
7974
return (
8075
<div className="flex flex-col gap-2 relative h-6" ref={menuRef}>
8176
<button

0 commit comments

Comments
 (0)