4 Commits

Author SHA1 Message Date
joungmin
18776b9b4b 바텀시트 필터 글씨 크기 미세 조정
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:15:59 +09:00
joungmin
177532e6e7 모바일 필터 바텀시트 UI 적용
- FilterSheet 컴포넌트 신규: 바텀시트로 올라오는 필터 선택 UI
- 장르/가격/지역 필터 모두 네이티브 select 대신 바텀시트 사용
- 카테고리별 그룹핑 + sticky 헤더 + 선택 체크 표시
- slide-up 애니메이션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:13:46 +09:00
joungmin
64d58cb553 모바일 필터 UI pill 스타일로 개선
- select를 둥근 칩(pill) 형태로 변경 (아이콘 + 드롭다운 화살표)
- 선택 시 브랜드 컬러 배경 + 링 하이라이트
- 장르/가격/지역 필터 모두 동일 스타일 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:08:42 +09:00
joungmin
a766a74f20 모바일 리스트 레이아웃 개선 + 내위치 줌 조정
- 식당명/지역/별점 1줄, 종류+가격(왼)+유튜브채널(우) 2줄, 태그 3줄 배치
- 가격대: 종류가 공간 우선 차지, 나머지에서 truncate
- 내위치 줌 16→17로 조정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:04:32 +09:00
4 changed files with 271 additions and 85 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");
@@ -344,7 +374,7 @@ export default function Home() {
navigator.geolocation.getCurrentPosition(
(pos) => {
setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude });
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 16 });
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 17 });
},
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 }),
{ timeout: 5000 },
@@ -957,37 +987,30 @@ export default function Home() {
<div className="space-y-1.5">
{/* Line 1: 음식 장르 + 가격 + 결과수 */}
<div className="flex items-center gap-1.5 text-xs">
<select
value={cuisineFilter}
onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
cuisineFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
<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"
}`}
>
<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}`}>
&nbsp;&nbsp;{item}
</option>
))}
</optgroup>
))}
</select>
<select
value={priceFilter}
onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
priceFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
<Icon name="restaurant" size={14} className={`mr-1 ${cuisineFilter ? "text-brand-500" : "text-gray-400"}`} />
<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"
}`}
>
<option value="">💰 </option>
{PRICE_GROUPS.map((g) => (
<option key={g.label} value={g.label}>{g.label}</option>
))}
</select>
<Icon name="payments" size={14} className={`mr-1 ${priceFilter ? "text-brand-500" : "text-gray-400"}`} />
<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} />
@@ -997,45 +1020,43 @@ export default function Home() {
</div>
{/* Line 2: 나라 + 시 + 구 + 내위치 */}
<div className="flex items-center gap-1.5 text-xs">
<select
value={countryFilter}
onChange={(e) => handleCountryChange(e.target.value)}
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
countryFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
<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"
}`}
>
<option value="">🌍 </option>
{countries.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<Icon name="public" size={14} className={`mr-1 ${countryFilter ? "text-brand-500" : "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 && (
<select
value={cityFilter}
onChange={(e) => handleCityChange(e.target.value)}
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
cityFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
<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>
{cities.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<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 && (
<select
value={districtFilter}
onChange={(e) => handleDistrictChange(e.target.value)}
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
districtFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
<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"
}`}
>
<option value="">/</option>
{districts.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
<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">
@@ -1285,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 className="text-[15px]"></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.5 text-xs font-semibold text-gray-400 dark:text-gray-500 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-[15px]">{opt.label}</span>
{value === opt.value && <Icon name="check" size={18} className="text-brand-500" />}
</button>
))}
</div>
))}
</div>
</div>
</>
);
}

View File

@@ -44,28 +44,44 @@ export default function RestaurantList({
: "bg-surface border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<div className="flex items-start justify-between gap-2">
<h4 className="font-bold text-[15px] text-gray-900 dark:text-gray-100">
{/* 1줄: 식당명 + 지역 + 별점 (전체 폭) */}
<div className="flex items-baseline gap-1.5 flex-wrap">
<h4 className="font-bold text-[15px] text-gray-900 dark:text-gray-100 shrink-0">
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
{r.name}
</h4>
{r.region && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</span>
)}
{r.rating && (
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0">
{r.rating}
</span>
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0"> {r.rating}</span>
)}
</div>
<div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1.5 text-xs">
{r.cuisine_type && (
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.cuisine_type}</span>
)}
{r.price_range && (
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.price_range}</span>
{/* 2줄: 종류/가격(왼) + 유튜브채널(우) */}
<div className="flex items-center gap-2 mt-1.5">
<div className="flex gap-x-2 text-xs flex-1 min-w-0">
{r.cuisine_type && (
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400 shrink-0">{r.cuisine_type}</span>
)}
{r.price_range && (
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400 truncate min-w-0">{r.price_range}</span>
)}
</div>
{r.channels && r.channels.length > 0 && (
<div className="shrink-0 flex flex-wrap gap-1 justify-end">
{r.channels.map((ch) => (
<span
key={ch}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium truncate max-w-[120px]"
>
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
<span className="truncate">{ch}</span>
</span>
))}
</div>
)}
</div>
{r.region && (
<p className="mt-1 text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</p>
)}
{/* 3줄: 태그 (전체 폭) */}
{r.foods_mentioned && r.foods_mentioned.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{r.foods_mentioned.slice(0, 5).map((f, i) => (
@@ -81,19 +97,6 @@ export default function RestaurantList({
)}
</div>
)}
{r.channels && r.channels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{r.channels.map((ch) => (
<span
key={ch}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium"
>
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
{ch}
</span>
))}
</div>
)}
</button>
))}
</div>