모바일 필터 바텀시트 UI 적용

- FilterSheet 컴포넌트 신규: 바텀시트로 올라오는 필터 선택 UI
- 장르/가격/지역 필터 모두 네이티브 select 대신 바텀시트 사용
- 카테고리별 그룹핑 + sticky 헤더 + 선택 체크 표시
- slide-up 애니메이션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-12 20:13:46 +09:00
parent 64d58cb553
commit 177532e6e7
3 changed files with 246 additions and 91 deletions

View File

@@ -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;
}

View File

@@ -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<FlyTo | null>(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<FilterOption[]>(() => {
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<FilterOption[]>(() =>
PRICE_GROUPS.map((g) => ({ label: g.label, value: g.label })),
[]);
const countryOptions = useMemo<FilterOption[]>(() =>
countries.map((c) => ({ label: c, value: c })),
[countries]);
const cityOptions = useMemo<FilterOption[]>(() =>
cities.map((c) => ({ label: c, value: c })),
[cities]);
const districtOptions = useMemo<FilterOption[]>(() =>
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() {
<div className="space-y-1.5">
{/* Line 1: 음식 장르 + 가격 + 결과수 */}
<div className="flex items-center gap-1.5 text-xs">
<div className={`relative inline-flex items-center rounded-full px-3 py-1.5 ${
cuisineFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}>
<button
onClick={() => setOpenSheet("cuisine")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
cuisineFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<Icon name="restaurant" size={14} className={`mr-1 ${cuisineFilter ? "text-brand-500" : "text-gray-400"}`} />
<select
value={cuisineFilter}
onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
className={`bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer ${
cuisineFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
>
<option value=""></option>
{CUISINE_TAXONOMY.map((g) => (
<optgroup key={g.category} label={`── ${g.category} ──`}>
<option value={g.category}>{g.category} </option>
{g.items.map((item) => (
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
{item}
</option>
))}
</optgroup>
))}
</select>
<Icon name="expand_more" size={14} className="absolute right-2 text-gray-400 pointer-events-none" />
</div>
<div className={`relative inline-flex items-center rounded-full px-3 py-1.5 ${
priceFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}>
<span className={cuisineFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{cuisineFilter ? (cuisineFilter.includes("|") ? cuisineFilter.split("|")[1] : cuisineFilter) : "장르"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
<button
onClick={() => setOpenSheet("price")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
priceFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<Icon name="payments" size={14} className={`mr-1 ${priceFilter ? "text-brand-500" : "text-gray-400"}`} />
<select
value={priceFilter}
onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
className={`bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer ${
priceFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
>
<option value=""></option>
{PRICE_GROUPS.map((g) => (
<option key={g.label} value={g.label}>{g.label}</option>
))}
</select>
<Icon name="expand_more" size={14} className="absolute right-2 text-gray-400 pointer-events-none" />
</div>
<span className={priceFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{priceFilter || "가격"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
{(cuisineFilter || priceFilter) && (
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); }} className="text-gray-400 hover:text-brand-500">
<Icon name="close" size={14} />
@@ -1009,61 +1020,43 @@ export default function Home() {
</div>
{/* Line 2: 나라 + 시 + 구 + 내위치 */}
<div className="flex items-center gap-1.5 text-xs">
<div className={`relative inline-flex items-center rounded-full px-3 py-1.5 ${
countryFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}>
<button
onClick={() => setOpenSheet("country")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
countryFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<Icon name="public" size={14} className={`mr-1 ${countryFilter ? "text-brand-500" : "text-gray-400"}`} />
<select
value={countryFilter}
onChange={(e) => handleCountryChange(e.target.value)}
className={`bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer ${
countryFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
<span className={countryFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{countryFilter || "나라"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
{countryFilter && cities.length > 0 && (
<button
onClick={() => setOpenSheet("city")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
cityFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<option value=""></option>
{countries.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<Icon name="expand_more" size={14} className="absolute right-2 text-gray-400 pointer-events-none" />
</div>
{countryFilter && cities.length > 0 && (
<div className={`relative inline-flex items-center rounded-full px-3 py-1.5 ${
cityFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}>
<select
value={cityFilter}
onChange={(e) => handleCityChange(e.target.value)}
className={`bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer ${
cityFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
>
<option value="">/</option>
{cities.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<Icon name="expand_more" size={14} className="absolute right-2 text-gray-400 pointer-events-none" />
</div>
<span className={cityFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{cityFilter || "시/도"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
)}
{cityFilter && districts.length > 0 && (
<div className={`relative inline-flex items-center rounded-full px-3 py-1.5 ${
districtFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}>
<select
value={districtFilter}
onChange={(e) => handleDistrictChange(e.target.value)}
className={`bg-transparent border-none outline-none appearance-none pr-4 cursor-pointer ${
districtFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
>
<option value="">/</option>
{districts.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
<Icon name="expand_more" size={14} className="absolute right-2 text-gray-400 pointer-events-none" />
</div>
<button
onClick={() => setOpenSheet("district")}
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
districtFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
}`}
>
<span className={districtFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
{districtFilter || "구/군"}
</span>
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
</button>
)}
{countryFilter && (
<button onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }} className="text-gray-400 hover:text-brand-500">
@@ -1313,6 +1306,47 @@ export default function Home() {
SDJ Labs Co., Ltd.
</span>
</footer>
{/* Mobile Filter Sheets */}
<FilterSheet
open={openSheet === "cuisine"}
onClose={() => setOpenSheet(null)}
title="음식 장르"
options={cuisineOptions}
value={cuisineFilter}
onChange={(v) => { setCuisineFilter(v); if (v) setBoundsFilterOn(false); }}
/>
<FilterSheet
open={openSheet === "price"}
onClose={() => setOpenSheet(null)}
title="가격대"
options={priceOptions}
value={priceFilter}
onChange={(v) => { setPriceFilter(v); if (v) setBoundsFilterOn(false); }}
/>
<FilterSheet
open={openSheet === "country"}
onClose={() => setOpenSheet(null)}
title="나라"
options={countryOptions}
value={countryFilter}
onChange={handleCountryChange}
/>
<FilterSheet
open={openSheet === "city"}
onClose={() => setOpenSheet(null)}
title="시/도"
options={cityOptions}
value={cityFilter}
onChange={handleCityChange}
/>
<FilterSheet
open={openSheet === "district"}
onClose={() => setOpenSheet(null)}
title="구/군"
options={districtOptions}
value={districtFilter}
onChange={handleDistrictChange}
/>
</div>
);
}

View File

@@ -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<HTMLDivElement>(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<Record<string, FilterOption[]>>((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 */}
<div
className="fixed inset-0 z-[60] bg-black/30 md:hidden"
onClick={onClose}
/>
{/* Sheet */}
<div
ref={sheetRef}
className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface rounded-t-2xl shadow-2xl max-h-[70vh] flex flex-col animate-slide-up"
>
{/* Handle */}
<div className="flex justify-center pt-2 pb-1 shrink-0">
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
<h3 className="font-bold text-base text-gray-900 dark:text-gray-100">{title}</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600">
<Icon name="close" size={20} />
</button>
</div>
{/* Options */}
<div className="flex-1 overflow-y-auto overscroll-contain pb-safe">
{/* 전체(초기화) */}
<button
onClick={() => handleSelect("")}
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 ${
!value ? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20" : "text-gray-700 dark:text-gray-300"
}`}
>
<span></span>
{!value && <Icon name="check" size={18} className="text-brand-500" />}
</button>
{groups.map((group) => (
<div key={group}>
{group && (
<div className="px-4 py-2 text-[11px] font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider bg-gray-50 dark:bg-gray-800/50 sticky top-0">
{group}
</div>
)}
{grouped[group].map((opt) => (
<button
key={opt.value}
onClick={() => handleSelect(opt.value)}
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 active:bg-gray-100 dark:active:bg-gray-800 ${
value === opt.value
? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20"
: "text-gray-700 dark:text-gray-300"
}`}
>
<span className="text-sm">{opt.label}</span>
{value === opt.value && <Icon name="check" size={18} className="text-brand-500" />}
</button>
))}
</div>
))}
</div>
</div>
</>
);
}