From 177532e6e7f606f7488ca358d2ed3eaae874a49a Mon Sep 17 00:00:00 2001 From: joungmin Date: Thu, 12 Mar 2026 20:13:46 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EB=B0=94=ED=85=80=EC=8B=9C=ED=8A=B8=20UI=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FilterSheet 컴포넌트 신규: 바텀시트로 올라오는 필터 선택 UI - 장르/가격/지역 필터 모두 네이티브 select 대신 바텀시트 사용 - 카테고리별 그룹핑 + sticky 헤더 + 선택 체크 표시 - slide-up 애니메이션 추가 Co-Authored-By: Claude Opus 4.6 --- frontend/src/app/globals.css | 9 + frontend/src/app/page.tsx | 216 ++++++++++++++---------- frontend/src/components/FilterSheet.tsx | 112 ++++++++++++ 3 files changed, 246 insertions(+), 91 deletions(-) create mode 100644 frontend/src/components/FilterSheet.tsx diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c8fe978..3dd33eb 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -101,3 +101,12 @@ html, body, #__next { .safe-area-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); } + +/* Filter sheet slide-up animation */ +@keyframes slide-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} +.animate-slide-up { + animation: slide-up 0.25s ease-out; +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 9e6c1a8..e93cbef 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -12,6 +12,7 @@ import RestaurantList from "@/components/RestaurantList"; import RestaurantDetail from "@/components/RestaurantDetail"; import MyReviewsList from "@/components/MyReviewsList"; import BottomSheet from "@/components/BottomSheet"; +import FilterSheet, { FilterOption } from "@/components/FilterSheet"; import { getCuisineIcon } from "@/lib/cuisine-icons"; import Icon from "@/components/Icon"; @@ -187,6 +188,7 @@ export default function Home() { const [countryFilter, setCountryFilter] = useState(""); const [cityFilter, setCityFilter] = useState(""); const [districtFilter, setDistrictFilter] = useState(""); + const [openSheet, setOpenSheet] = useState<"cuisine" | "price" | "country" | "city" | "district" | null>(null); const [regionFlyTo, setRegionFlyTo] = useState(null); const [showFavorites, setShowFavorites] = useState(false); const [showMyReviews, setShowMyReviews] = useState(false); @@ -247,6 +249,34 @@ export default function Home() { }); }, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]); + // FilterSheet option builders + const cuisineOptions = useMemo(() => { + const opts: FilterOption[] = []; + for (const g of CUISINE_TAXONOMY) { + opts.push({ label: `${g.category} 전체`, value: g.category, group: g.category }); + for (const item of g.items) { + opts.push({ label: item, value: `${g.category}|${item}`, group: g.category }); + } + } + return opts; + }, []); + + const priceOptions = useMemo(() => + PRICE_GROUPS.map((g) => ({ label: g.label, value: g.label })), + []); + + const countryOptions = useMemo(() => + countries.map((c) => ({ label: c, value: c })), + [countries]); + + const cityOptions = useMemo(() => + cities.map((c) => ({ label: c, value: c })), + [cities]); + + const districtOptions = useMemo(() => + districts.map((d) => ({ label: d, value: d })), + [districts]); + // Set desktop default to map mode on mount + get user location useEffect(() => { if (window.innerWidth >= 768) setViewMode("map"); @@ -957,49 +987,30 @@ export default function Home() {
{/* Line 1: 음식 장르 + 가격 + 결과수 */}
-
+
-
+ + {cuisineFilter ? (cuisineFilter.includes("|") ? cuisineFilter.split("|")[1] : cuisineFilter) : "장르"} + + + +
+ + {priceFilter || "가격"} + + + {(cuisineFilter || priceFilter) && (
{/* Line 2: 나라 + 시 + 구 + 내위치 */}
-
+
- {countryFilter && cities.length > 0 && ( -
- - -
+ + {cityFilter || "시/도"} + + + )} {cityFilter && districts.length > 0 && ( -
- - -
+ )} {countryFilter && (
); } diff --git a/frontend/src/components/FilterSheet.tsx b/frontend/src/components/FilterSheet.tsx new file mode 100644 index 0000000..689bffb --- /dev/null +++ b/frontend/src/components/FilterSheet.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import Icon from "@/components/Icon"; + +export interface FilterOption { + label: string; + value: string; + group?: string; +} + +interface FilterSheetProps { + open: boolean; + onClose: () => void; + title: string; + options: FilterOption[]; + value: string; + onChange: (value: string) => void; +} + +export default function FilterSheet({ open, onClose, title, options, value, onChange }: FilterSheetProps) { + const sheetRef = useRef(null); + + useEffect(() => { + if (!open) return; + document.body.style.overflow = "hidden"; + return () => { document.body.style.overflow = ""; }; + }, [open]); + + // Group options by group field + const grouped = options.reduce>((acc, opt) => { + const key = opt.group || ""; + if (!acc[key]) acc[key] = []; + acc[key].push(opt); + return acc; + }, {}); + const groups = Object.keys(grouped); + + const handleSelect = (v: string) => { + onChange(v); + onClose(); + }; + + if (!open) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Sheet */} +
+ {/* Handle */} +
+
+
+ + {/* Header */} +
+

{title}

+ +
+ + {/* Options */} +
+ {/* 전체(초기화) */} + + + {groups.map((group) => ( +
+ {group && ( +
+ {group} +
+ )} + {grouped[group].map((opt) => ( + + ))} +
+ ))} +
+
+ + ); +}