UI/UX 개선: 모바일 네비게이션, 로그인 모달, 지도 기능, 캐치테이블 연동

- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가
- 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능)
- 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가
- 채널 필터 시 해당 채널만 마커/범례 표시
- 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴
- 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용)
- 테이블링/캐치테이블 버튼 UI 차별화
- Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-11 00:49:16 +09:00
parent 58c0f972e2
commit cdee37e341
23 changed files with 1465 additions and 325 deletions

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GoogleLogin } from "@react-oauth/google";
import LoginMenu from "@/components/LoginMenu";
import { api } from "@/lib/api";
import type { Restaurant, Channel, Review } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
@@ -140,6 +141,7 @@ export default function Home() {
const [cuisineFilter, setCuisineFilter] = useState("");
const [priceFilter, setPriceFilter] = useState("");
const [viewMode, setViewMode] = useState<"map" | "list">("list");
const [mobileTab, setMobileTab] = useState<"home" | "list" | "nearby" | "favorites" | "profile">("home");
const [showMobileFilters, setShowMobileFilters] = useState(false);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const [boundsFilterOn, setBoundsFilterOn] = useState(false);
@@ -151,6 +153,7 @@ export default function Home() {
const [showMyReviews, setShowMyReviews] = useState(false);
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
const [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
const [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 });
const geoApplied = useRef(false);
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
@@ -169,6 +172,8 @@ export default function Home() {
}, [regionTree, countryFilter, cityFilter]);
const filteredRestaurants = useMemo(() => {
const dist = (r: Restaurant) =>
(r.latitude - userLoc.lat) ** 2 + (r.longitude - userLoc.lng) ** 2;
return restaurants.filter((r) => {
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
@@ -184,12 +189,23 @@ export default function Home() {
if (r.longitude < mapBounds.west || r.longitude > mapBounds.east) return false;
}
return true;
}).sort((a, b) => {
const da = dist(a), db = dist(b);
if (da !== db) return da - db;
return (b.rating || 0) - (a.rating || 0);
});
}, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds]);
}, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
// Set desktop default to map mode on mount
// Set desktop default to map mode on mount + get user location
useEffect(() => {
if (window.innerWidth >= 768) setViewMode("map");
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => {},
{ timeout: 5000 },
);
}
}, []);
// Load channels + record visit on mount
@@ -220,12 +236,9 @@ export default function Home() {
if (match) {
setCountryFilter(match.country);
setCityFilter(match.city);
const matched = restaurants.filter((r) => {
const p = parseRegion(r.region);
return p && p.country === match.country && p.city === match.city;
});
setRegionFlyTo(computeFlyTo(matched));
}
const mobile = window.innerWidth < 768;
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: mobile ? 13 : 16 });
},
() => { /* user denied or error — do nothing */ },
{ timeout: 5000 },
@@ -262,6 +275,21 @@ export default function Home() {
setMapBounds(bounds);
}, []);
const handleMyLocation = useCallback(() => {
if (navigator.geolocation) {
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: 37.498, lng: 127.0276, zoom: 16 }),
{ timeout: 5000 },
);
} else {
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 });
}
}, []);
const handleCountryChange = useCallback((country: string) => {
setCountryFilter(country);
setCityFilter("");
@@ -333,6 +361,75 @@ export default function Home() {
.finally(() => setLoading(false));
}, []);
const handleMobileTab = useCallback(async (tab: "home" | "list" | "nearby" | "favorites" | "profile") => {
// 홈 탭 재클릭 = 리셋
if (tab === "home" && mobileTab === "home") {
handleReset();
return;
}
setMobileTab(tab);
setShowDetail(false);
setShowMobileFilters(false);
setSelected(null);
if (tab === "nearby") {
setBoundsFilterOn(true);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 13 }),
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 13 }),
{ timeout: 5000 },
);
} else {
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 13 });
}
// 내주변에서 돌아올 때를 위해 favorites/reviews 해제
if (showFavorites || showMyReviews) {
setShowFavorites(false);
setShowMyReviews(false);
setMyReviews([]);
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants);
}
return;
}
setBoundsFilterOn(false);
if (tab === "favorites") {
if (!user) return;
setShowMyReviews(false);
setMyReviews([]);
try {
const favs = await api.getMyFavorites();
setRestaurants(favs);
setShowFavorites(true);
} catch { /* ignore */ }
} else if (tab === "profile") {
if (!user) return;
setShowFavorites(false);
try {
const reviews = await api.getMyReviews();
setMyReviews(reviews);
setShowMyReviews(true);
} catch { /* ignore */ }
// 프로필에서는 식당 목록을 원래대로 복원
if (showFavorites) {
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants);
}
} else {
// 홈 / 식당 목록 - 항상 전체 식당 목록으로 복원
const needReload = showFavorites || showMyReviews;
setShowFavorites(false);
setShowMyReviews(false);
setMyReviews([]);
if (needReload) {
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined });
setRestaurants(data);
}
}
}, [user, showFavorites, showMyReviews, channelFilter, mobileTab, handleReset]);
const handleToggleFavorites = async () => {
if (showFavorites) {
setShowFavorites(false);
@@ -436,6 +533,13 @@ export default function Home() {
<div className="w-96 shrink-0">
<SearchBar onSearch={handleSearch} isLoading={loading} />
</div>
<button
onClick={handleReset}
className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-orange-500 dark:hover:text-orange-400 transition-colors"
title="초기화"
>
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
</button>
<select
value={channelFilter}
onChange={(e) => {
@@ -445,7 +549,7 @@ export default function Home() {
}}
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
>
<option value="">📺 </option>
<option value="">📺 </option>
{channels.map((ch) => (
<option key={ch.id} value={ch.channel_name}>
📺 {ch.channel_name}
@@ -457,7 +561,7 @@ export default function Home() {
onChange={(e) => setCuisineFilter(e.target.value)}
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
>
<option value="">🍽 </option>
<option value="">🍽 </option>
{CUISINE_TAXONOMY.map((g) => (
<optgroup key={g.category} label={`── ${g.category} ──`}>
<option value={g.category}>🍽 {g.category} </option>
@@ -474,7 +578,7 @@ export default function Home() {
onChange={(e) => setPriceFilter(e.target.value)}
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
>
<option value="">💰 </option>
<option value="">💰 </option>
{PRICE_GROUPS.map((g) => (
<option key={g.label} value={g.label}>{g.label}</option>
))}
@@ -487,7 +591,7 @@ export default function Home() {
onChange={(e) => handleCountryChange(e.target.value)}
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
>
<option value="">🌍 </option>
<option value="">🌍 </option>
{countries.map((c) => (
<option key={c} value={c}>🌍 {c}</option>
))}
@@ -518,15 +622,29 @@ export default function Home() {
)}
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700" />
<button
onClick={() => setBoundsFilterOn(!boundsFilterOn)}
onClick={() => {
const next = !boundsFilterOn;
setBoundsFilterOn(next);
if (next) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
{ timeout: 5000 },
);
} else {
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
}
}
}}
className={`px-3 py-1.5 text-sm border rounded-lg transition-colors ${
boundsFilterOn
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
: "hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
title="지도 영역 내 식당만 표시"
title="내 위치 주변 식당만 표시"
>
{boundsFilterOn ? "📍 영역 필터" : "📍 영역"}
{boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"}
</button>
<button
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
@@ -591,74 +709,32 @@ export default function Home() {
</button>
</div>
) : (
<GoogleLogin
onSuccess={(credentialResponse) => {
if (credentialResponse.credential) {
login(credentialResponse.credential).catch(console.error);
}
}}
onError={() => console.error("Google login failed")}
size="small"
/>
<LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
)}
</div>
</div>
{/* ── Header row 2 (mobile only): search + toolbar ── */}
<div className="md:hidden px-4 pb-3 space-y-2">
<div className={`md:hidden px-4 pb-3 space-y-2 ${mobileTab === "favorites" || mobileTab === "profile" ? "hidden" : ""}`}>
{/* Row 1: Search */}
<SearchBar onSearch={handleSearch} isLoading={loading} />
{/* Row 2: Toolbar */}
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
className={`px-3 py-1.5 text-xs border rounded-lg transition-colors ${
viewMode === "map"
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
: "text-gray-600"
}`}
>
{viewMode === "map" ? "🗺 지도" : "☰ 리스트"}
</button>
<button
onClick={() => setShowMobileFilters(!showMobileFilters)}
className={`px-3 py-1.5 text-xs border rounded-lg transition-colors relative ${
showMobileFilters || channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn
showMobileFilters || channelFilter || cuisineFilter || priceFilter || countryFilter
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
: "text-gray-600"
}`}
>
{showMobileFilters ? "✕ 닫기" : "🔽 필터"}
{!showMobileFilters && (channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn) && (
{!showMobileFilters && (channelFilter || cuisineFilter || priceFilter || countryFilter) && (
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-orange-500 text-white rounded-full text-[9px] flex items-center justify-center">
{[channelFilter, cuisineFilter, priceFilter, countryFilter, boundsFilterOn].filter(Boolean).length}
{[channelFilter, cuisineFilter, priceFilter, countryFilter].filter(Boolean).length}
</span>
)}
</button>
{user && (
<>
<button
onClick={handleToggleFavorites}
className={`px-3 py-1.5 text-xs rounded-full border transition-colors ${
showFavorites
? "bg-rose-50 dark:bg-rose-900/30 border-rose-300 dark:border-rose-700 text-rose-600 dark:text-rose-400"
: "border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400"
}`}
>
{showFavorites ? "♥ 찜" : "♡ 찜"}
</button>
<button
onClick={handleToggleMyReviews}
className={`px-3 py-1.5 text-xs rounded-full border transition-colors ${
showMyReviews
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
: "border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400"
}`}
>
{showMyReviews ? "✎ 리뷰" : "✎ 리뷰"}
</button>
</>
)}
<span className="text-xs text-gray-400 ml-auto">
{filteredRestaurants.length}
</span>
@@ -678,7 +754,7 @@ export default function Home() {
}}
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
>
<option value="">📺 </option>
<option value="">📺 </option>
{channels.map((ch) => (
<option key={ch.id} value={ch.channel_name}>
📺 {ch.channel_name}
@@ -690,7 +766,7 @@ export default function Home() {
onChange={(e) => setCuisineFilter(e.target.value)}
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
>
<option value="">🍽 </option>
<option value="">🍽 </option>
{CUISINE_TAXONOMY.map((g) => (
<optgroup key={g.category} label={`── ${g.category} ──`}>
<option value={g.category}>🍽 {g.category} </option>
@@ -707,7 +783,7 @@ export default function Home() {
onChange={(e) => setPriceFilter(e.target.value)}
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
>
<option value="">💰 </option>
<option value="">💰 </option>
{PRICE_GROUPS.map((g) => (
<option key={g.label} value={g.label}>💰 {g.label}</option>
))}
@@ -720,7 +796,7 @@ export default function Home() {
onChange={(e) => handleCountryChange(e.target.value)}
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
>
<option value="">🌍 </option>
<option value="">🌍 </option>
{countries.map((c) => (
<option key={c} value={c}>🌍 {c}</option>
))}
@@ -753,14 +829,28 @@ export default function Home() {
{/* Toggle buttons */}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setBoundsFilterOn(!boundsFilterOn)}
onClick={() => {
const next = !boundsFilterOn;
setBoundsFilterOn(next);
if (next) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
{ timeout: 5000 },
);
} else {
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
}
}
}}
className={`px-2.5 py-1.5 text-xs border rounded-lg transition-colors ${
boundsFilterOn
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
: "text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800"
}`}
>
{boundsFilterOn ? "📍 영역 ON" : "📍 영역"}
{boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"}
</button>
</div>
</div>
@@ -784,6 +874,8 @@ export default function Home() {
onSelectRestaurant={handleSelectRestaurant}
onBoundsChanged={handleBoundsChanged}
flyTo={regionFlyTo}
onMyLocation={handleMyLocation}
activeChannel={channelFilter || undefined}
/>
{visits && (
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm">
@@ -804,6 +896,8 @@ export default function Home() {
onSelectRestaurant={handleSelectRestaurant}
onBoundsChanged={handleBoundsChanged}
flyTo={regionFlyTo}
onMyLocation={handleMyLocation}
activeChannel={channelFilter || undefined}
/>
{visits && (
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm">
@@ -816,68 +910,115 @@ export default function Home() {
</div>
{/* Mobile layout */}
<div className="md:hidden flex-1 flex flex-col overflow-hidden">
{viewMode === "map" ? (
<>
<div className="flex-1 relative">
<div className="md:hidden flex-1 flex flex-col overflow-hidden pb-14">
{/* Tab content — takes all remaining space above fixed nav */}
{mobileTab === "nearby" ? (
/* 내주변: 지도 + 리스트 분할, 영역필터 ON */
<div className="flex-1 flex flex-col overflow-hidden">
<div className="h-[45%] relative shrink-0">
<MapView
restaurants={filteredRestaurants}
selected={selected}
onSelectRestaurant={handleSelectRestaurant}
onBoundsChanged={handleBoundsChanged}
flyTo={regionFlyTo}
onMyLocation={handleMyLocation}
activeChannel={channelFilter || undefined}
/>
<div className="absolute top-2 left-2 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm rounded-lg px-3 py-1.5 shadow-sm z-10">
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
{filteredRestaurants.length}
</span>
</div>
{visits && (
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm z-10">
{visits.today} · {visits.total.toLocaleString()}
</div>
)}
</div>
</>
) : (
<>
{/* List area — if selected, show single row; otherwise full list */}
{selected ? (
<div
className="shrink-0 bg-white dark:bg-gray-950 border-b dark:border-gray-800 px-3 py-2 flex items-center gap-2 cursor-pointer"
onClick={() => { setSelected(null); setShowDetail(false); }}
>
<span className="text-base">{getCuisineIcon(selected.cuisine_type)}</span>
<span className="font-semibold text-sm truncate flex-1">{selected.name}</span>
{selected.rating && (
<span className="text-xs text-gray-500"><span className="text-yellow-500"></span> {selected.rating}</span>
)}
{selected.cuisine_type && (
<span className="text-xs text-gray-400">{selected.cuisine_type}</span>
)}
<button
onClick={(e) => { e.stopPropagation(); setSelected(null); setShowDetail(false); }}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none ml-1"
>
</button>
<div className="flex-1 overflow-y-auto">
{mobileListContent}
</div>
</div>
) : mobileTab === "profile" ? (
/* 내정보 */
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
{!user ? (
<div className="flex flex-col items-center justify-center h-full gap-4 px-6">
<p className="text-gray-500 dark:text-gray-400 text-sm"> </p>
<GoogleLogin
onSuccess={(res) => {
if (res.credential) login(res.credential).catch(console.error);
}}
onError={() => console.error("Google login failed")}
size="large"
text="signin_with"
/>
</div>
) : (
<div className="max-h-[360px] bg-white dark:bg-gray-950 overflow-y-auto border-b dark:border-gray-800">
{mobileListContent}
<div className="p-4 space-y-4">
{/* 프로필 헤더 */}
<div className="flex items-center gap-3 pb-4 border-b dark:border-gray-800">
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-12 h-12 rounded-full border border-gray-200" />
) : (
<div className="w-12 h-12 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-lg font-semibold border border-orange-200">
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
</div>
)}
<div className="flex-1">
<p className="font-semibold text-sm dark:text-gray-100">{user.nickname || user.email}</p>
{user.nickname && user.email && (
<p className="text-xs text-gray-400">{user.email}</p>
)}
</div>
<button
onClick={logout}
className="px-3 py-1.5 text-xs text-gray-500 border border-gray-300 dark:border-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
>
</button>
</div>
{/* 내 리뷰 */}
<div>
<h3 className="font-semibold text-sm mb-2 dark:text-gray-200"> </h3>
{myReviews.length === 0 ? (
<p className="text-sm text-gray-400"> </p>
) : (
<MyReviewsList
reviews={myReviews}
onClose={() => {}}
onSelectRestaurant={async (restaurantId) => {
try {
const r = await api.getRestaurant(restaurantId);
handleSelectRestaurant(r);
} catch { /* ignore */ }
}}
/>
)}
</div>
</div>
)}
{/* Map fills remaining space below the list */}
<div className="flex-1 relative">
<MapView
restaurants={filteredRestaurants}
selected={selected}
onSelectRestaurant={handleSelectRestaurant}
onBoundsChanged={handleBoundsChanged}
flyTo={regionFlyTo}
/>
{visits && (
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm z-10">
{visits.today} · {visits.total.toLocaleString()}
</div>
)}
</div>
</>
</div>
) : (
/* 홈 / 식당 목록 / 찜: 리스트 표시 */
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
{mobileTab === "favorites" && !user ? (
<div className="flex flex-col items-center justify-center h-full gap-4 px-6">
<p className="text-gray-500 dark:text-gray-400 text-sm"> </p>
<GoogleLogin
onSuccess={(res) => {
if (res.credential) login(res.credential).catch(console.error);
}}
onError={() => console.error("Google login failed")}
size="large"
text="signin_with"
/>
</div>
) : (
mobileListContent
)}
</div>
)}
{/* Mobile Bottom Sheet for restaurant detail */}
@@ -888,8 +1029,44 @@ export default function Home() {
</BottomSheet>
</div>
{/* Footer */}
<footer className="shrink-0 border-t dark:border-gray-800 bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm py-2.5 flex items-center justify-center gap-2 text-[11px] text-gray-400 dark:text-gray-500 group">
{/* ── Mobile Bottom Nav (fixed) ── */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t dark:border-gray-800 bg-white dark:bg-gray-950 safe-area-bottom">
<div className="flex items-stretch h-14">
{([
{ key: "home", label: "홈", icon: (
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3z"/></svg>
)},
{ key: "list", label: "식당 목록", icon: (
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M3 4h18v2H3zm0 7h18v2H3zm0 7h18v2H3z"/></svg>
)},
{ key: "nearby", label: "내주변", icon: (
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
)},
{ key: "favorites", label: "찜", icon: (
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
)},
{ key: "profile", label: "내정보", icon: (
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
)},
] as { key: "home" | "list" | "nearby" | "favorites" | "profile"; label: string; icon: React.ReactNode }[]).map((tab) => (
<button
key={tab.key}
onClick={() => handleMobileTab(tab.key)}
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2 transition-colors ${
mobileTab === tab.key
? "text-orange-600 dark:text-orange-400"
: "text-gray-400 dark:text-gray-500"
}`}
>
{tab.icon}
<span className="text-[10px] font-medium">{tab.label}</span>
</button>
))}
</div>
</nav>
{/* Desktop Footer */}
<footer className="hidden md:flex shrink-0 border-t dark:border-gray-800 bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm py-2.5 items-center justify-center gap-2 text-[11px] text-gray-400 dark:text-gray-500 group">
<div className="relative">
<img
src="/icon.jpg"