Files
tasteby/frontend/src/app/page.tsx
joungmin dda0da52c4 내위치 필터 모바일 리스트 적용 + 반경 4km
- mapBounds 없을 때 userLoc 기준 ~4km 반경 필터링
- 내위치 ON 시 setUserLoc도 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:25:36 +09:00

1360 lines
59 KiB
TypeScript

"use client";
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, Memo } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import MapView, { MapBounds, FlyTo } from "@/components/MapView";
import SearchBar from "@/components/SearchBar";
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";
function useDragScroll() {
const ref = useRef<HTMLDivElement>(null);
const drag = useRef({ active: false, startX: 0, sl: 0, moved: false });
const onMouseDown = useCallback((e: React.MouseEvent) => {
const el = ref.current;
if (!el) return;
drag.current = { active: true, startX: e.clientX, sl: el.scrollLeft, moved: false };
el.style.cursor = "grabbing";
}, []);
const onMouseMove = useCallback((e: React.MouseEvent) => {
const d = drag.current;
if (!d.active) return;
const el = ref.current;
if (!el) return;
const dx = e.clientX - d.startX;
if (Math.abs(dx) > 5) d.moved = true;
el.scrollLeft = d.sl - dx;
}, []);
const onMouseUp = useCallback(() => {
drag.current.active = false;
const el = ref.current;
if (el) el.style.cursor = "grab";
}, []);
const onClickCapture = useCallback((e: React.MouseEvent) => {
if (drag.current.moved) {
e.preventDefault();
e.stopPropagation();
drag.current.moved = false;
}
}, []);
return { ref, onMouseDown, onMouseMove, onMouseUp, onMouseLeave: onMouseUp, onClickCapture, style: { cursor: "grab" } as const };
}
const CUISINE_TAXONOMY: { category: string; items: string[] }[] = [
{ category: "한식", items: ["백반/한정식", "국밥/해장국", "찌개/전골/탕", "삼겹살/돼지구이", "소고기/한우구이", "곱창/막창", "닭/오리구이", "족발/보쌈", "회/횟집", "해산물", "분식", "면", "죽/죽집", "순대/순대국", "장어/민물", "주점/포차", "파인다이닝/코스"] },
{ category: "일식", items: ["스시/오마카세", "라멘", "돈카츠", "텐동/튀김", "이자카야", "야키니쿠", "카레", "소바/우동", "파인다이닝/코스"] },
{ category: "중식", items: ["중화요리", "마라/훠궈", "딤섬/만두", "양꼬치", "파인다이닝/코스"] },
{ category: "양식", items: ["파스타/이탈리안", "스테이크", "햄버거", "피자", "프렌치", "바베큐", "브런치", "비건/샐러드", "파인다이닝/코스"] },
{ category: "아시아", items: ["베트남", "태국", "인도/중동", "동남아기타"] },
{ category: "기타", items: ["치킨", "카페/디저트", "베이커리", "뷔페", "퓨전"] },
];
function matchCuisineFilter(cuisineType: string | null, filter: string): boolean {
if (!cuisineType || !filter) return false;
// filter can be a category ("한식") or full type ("한식|백반/한정식")
if (filter.includes("|")) return cuisineType === filter;
return cuisineType.startsWith(filter);
}
const PRICE_GROUPS: { label: string; test: (p: string) => boolean }[] = [
{
label: "저렴 (~1만원)",
test: (p) => /저렴|가성비|착한|만원 이하|[3-9]천원|^\d[,.]?\d*천원/.test(p) || /^[1]만원대$/.test(p) || /^[5-9],?\d{3}원/.test(p),
},
{
label: "보통 (1~3만원)",
test: (p) => /[1-2]만원대|1-[23]만|인당 [12]\d?,?\d*원|1[2-9],?\d{3}원|2[0-9],?\d{3}원/.test(p),
},
{
label: "고가 (3만원~)",
test: (p) => /[3-9]만원|고가|높은|묵직|살벌|10만원|5만원|4만원|6만원/.test(p),
},
];
function matchPriceGroup(priceRange: string | null, group: string): boolean {
if (!priceRange) return false;
const g = PRICE_GROUPS.find((g) => g.label === group);
if (!g) return false;
return g.test(priceRange);
}
/** Parse pipe-delimited region "나라|시|구" into parts. */
function parseRegion(region: string | null): { country: string; city: string; district: string } | null {
if (!region) return null;
const parts = region.split("|");
return {
country: parts[0] || "",
city: parts[1] || "",
district: parts[2] || "",
};
}
/** Build 3-level tree: country → city → district[] */
function buildRegionTree(restaurants: Restaurant[]) {
const tree = new Map<string, Map<string, Set<string>>>();
for (const r of restaurants) {
const p = parseRegion(r.region);
if (!p || !p.country || p.country === "나라") continue;
if (!tree.has(p.country)) tree.set(p.country, new Map());
const cityMap = tree.get(p.country)!;
if (p.city) {
if (!cityMap.has(p.city)) cityMap.set(p.city, new Set());
if (p.district) cityMap.get(p.city)!.add(p.district);
}
}
return tree;
}
/** Compute centroid + appropriate zoom from a set of restaurants. */
function computeFlyTo(rests: Restaurant[]): FlyTo | null {
if (rests.length === 0) return null;
const lat = rests.reduce((s, r) => s + r.latitude, 0) / rests.length;
const lng = rests.reduce((s, r) => s + r.longitude, 0) / rests.length;
// Pick zoom based on geographic spread
const latSpread = Math.max(...rests.map((r) => r.latitude)) - Math.min(...rests.map((r) => r.latitude));
const lngSpread = Math.max(...rests.map((r) => r.longitude)) - Math.min(...rests.map((r) => r.longitude));
const spread = Math.max(latSpread, lngSpread);
let zoom = 13;
if (spread > 2) zoom = 8;
else if (spread > 1) zoom = 9;
else if (spread > 0.5) zoom = 10;
else if (spread > 0.2) zoom = 11;
else if (spread > 0.1) zoom = 12;
else if (spread > 0.02) zoom = 14;
else zoom = 15;
return { lat, lng, zoom };
}
/** Find best matching country + city from user's coordinates using restaurant data. */
function findRegionFromCoords(
lat: number,
lng: number,
restaurants: Restaurant[],
): { country: string; city: string } | null {
// Group restaurants by country|city and compute centroids
const groups = new Map<string, { country: string; city: string; lats: number[]; lngs: number[] }>();
for (const r of restaurants) {
const p = parseRegion(r.region);
if (!p || !p.country || p.country === "나라" || !p.city) continue;
const key = `${p.country}|${p.city}`;
if (!groups.has(key)) groups.set(key, { country: p.country, city: p.city, lats: [], lngs: [] });
const g = groups.get(key)!;
g.lats.push(r.latitude);
g.lngs.push(r.longitude);
}
let best: { country: string; city: string } | null = null;
let bestDist = Infinity;
for (const g of groups.values()) {
const cLat = g.lats.reduce((a, b) => a + b, 0) / g.lats.length;
const cLng = g.lngs.reduce((a, b) => a + b, 0) / g.lngs.length;
const dist = (cLat - lat) ** 2 + (cLng - lng) ** 2;
if (dist < bestDist) {
bestDist = dist;
best = { country: g.country, city: g.city };
}
}
return best;
}
export default function Home() {
const { user, login, logout, isLoading: authLoading } = useAuth();
const [restaurants, setRestaurants] = useState<Restaurant[]>([]);
const [selected, setSelected] = useState<Restaurant | null>(null);
const [loading, setLoading] = useState(true);
const [showDetail, setShowDetail] = useState(false);
const [channels, setChannels] = useState<Channel[]>([]);
const [channelFilter, setChannelFilter] = useState("");
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);
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);
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
const [myMemos, setMyMemos] = useState<(Memo & { 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 [isSearchResult, setIsSearchResult] = useState(false);
const [resetCount, setResetCount] = useState(0);
const geoApplied = useRef(false);
const dd = useDragScroll();
const dm = useDragScroll();
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
const countries = useMemo(() => [...regionTree.keys()].sort(), [regionTree]);
const cities = useMemo(() => {
if (!countryFilter) return [];
const cityMap = regionTree.get(countryFilter);
return cityMap ? [...cityMap.keys()].sort() : [];
}, [regionTree, countryFilter]);
const districts = useMemo(() => {
if (!countryFilter || !cityFilter) return [];
const cityMap = regionTree.get(countryFilter);
if (!cityMap) return [];
const set = cityMap.get(cityFilter);
return set ? [...set].sort() : [];
}, [regionTree, countryFilter, cityFilter]);
const filteredRestaurants = useMemo(() => {
const dist = (r: Restaurant) =>
(r.latitude - userLoc.lat) ** 2 + (r.longitude - userLoc.lng) ** 2;
if (isSearchResult) {
return [...restaurants].sort((a, b) => {
const da = dist(a), db = dist(b);
if (da !== db) return da - db;
return (b.rating || 0) - (a.rating || 0);
});
}
return restaurants.filter((r) => {
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
if (priceFilter && !matchPriceGroup(r.price_range, priceFilter)) return false;
if (countryFilter) {
const parsed = parseRegion(r.region);
if (!parsed || parsed.country !== countryFilter) return false;
if (cityFilter && parsed.city !== cityFilter) return false;
if (districtFilter && parsed.district !== districtFilter) return false;
}
if (boundsFilterOn) {
if (mapBounds) {
if (r.latitude < mapBounds.south || r.latitude > mapBounds.north) return false;
if (r.longitude < mapBounds.west || r.longitude > mapBounds.east) return false;
} else {
// 지도가 없으면 (모바일 리스트 탭 등) userLoc 기준 ~2km 반경
const dlat = r.latitude - userLoc.lat;
const dlng = r.longitude - userLoc.lng;
if (dlat * dlat + dlng * dlng > 0.0013) return false; // ~4km
}
}
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, 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");
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => {},
{ timeout: 5000 },
);
}
}, []);
// Load channels + record visit on mount
useEffect(() => {
api.getChannels().then(setChannels).catch(console.error);
api.recordVisit().then(() => api.getVisits().then(setVisits)).catch(console.error);
}, []);
// Load all restaurants on mount
useEffect(() => {
setLoading(true);
setIsSearchResult(false);
api
.getRestaurants({ limit: 500 })
.then(setRestaurants)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
// Auto-select region from user's geolocation (once)
useEffect(() => {
if (geoApplied.current || restaurants.length === 0) return;
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(pos) => {
if (geoApplied.current) return;
geoApplied.current = true;
const match = findRegionFromCoords(pos.coords.latitude, pos.coords.longitude, restaurants);
if (match) {
setCountryFilter(match.country);
setCityFilter(match.city);
}
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 },
);
}, [restaurants]);
const handleSearch = useCallback(
async (query: string, mode: "keyword" | "semantic" | "hybrid") => {
setLoading(true);
try {
const results = await api.search(query, mode);
setRestaurants(results);
setSelected(null);
setShowDetail(false);
setIsSearchResult(true);
// 검색 시 필터 초기화
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
setBoundsFilterOn(false);
// 검색 결과에 맞게 지도 이동
const flyTo = computeFlyTo(results);
if (flyTo) setRegionFlyTo(flyTo);
} catch (e) {
console.error("Search failed:", e);
} finally {
setLoading(false);
}
},
[]
);
const handleSelectRestaurant = useCallback((r: Restaurant) => {
setSelected(r);
setShowDetail(true);
}, []);
const handleCloseDetail = useCallback(() => {
setShowDetail(false);
}, []);
const handleBoundsChanged = useCallback((bounds: MapBounds) => {
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: 17 });
},
() => 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("");
setDistrictFilter("");
setBoundsFilterOn(false);
if (!country) { setRegionFlyTo(null); return; }
const matched = restaurants.filter((r) => {
const p = parseRegion(r.region);
return p && p.country === country;
});
setRegionFlyTo(computeFlyTo(matched));
}, [restaurants]);
const handleCityChange = useCallback((city: string) => {
setCityFilter(city);
setDistrictFilter("");
if (!city) {
// Re-fly to country level
const matched = restaurants.filter((r) => {
const p = parseRegion(r.region);
return p && p.country === countryFilter;
});
setRegionFlyTo(computeFlyTo(matched));
return;
}
const matched = restaurants.filter((r) => {
const p = parseRegion(r.region);
return p && p.country === countryFilter && p.city === city;
});
setRegionFlyTo(computeFlyTo(matched));
}, [restaurants, countryFilter]);
const handleDistrictChange = useCallback((district: string) => {
setDistrictFilter(district);
if (!district) {
const matched = restaurants.filter((r) => {
const p = parseRegion(r.region);
return p && p.country === countryFilter && p.city === cityFilter;
});
setRegionFlyTo(computeFlyTo(matched));
return;
}
const matched = restaurants.filter((r) => {
const p = parseRegion(r.region);
return p && p.country === countryFilter && p.city === cityFilter && p.district === district;
});
setRegionFlyTo(computeFlyTo(matched));
}, [restaurants, countryFilter, cityFilter]);
const handleReset = useCallback(() => {
setLoading(true);
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
setRegionFlyTo(null);
setBoundsFilterOn(false);
setShowFavorites(false);
setShowMyReviews(false);
setIsSearchResult(false);
setResetCount((c) => c + 1);
api
.getRestaurants({ limit: 500 })
.then((data) => {
setRestaurants(data);
setSelected(null);
setShowDetail(false);
})
.catch(console.error)
.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 }).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 }).then(setRestaurants);
}
} else {
// 홈 / 식당 목록 - 항상 전체 식당 목록으로 복원
const needReload = showFavorites || showMyReviews;
setShowFavorites(false);
setShowMyReviews(false);
setMyReviews([]);
if (needReload) {
const data = await api.getRestaurants({ limit: 500 });
setRestaurants(data);
}
}
}, [user, showFavorites, showMyReviews, mobileTab, handleReset]);
const handleToggleFavorites = async () => {
if (showFavorites) {
setShowFavorites(false);
const data = await api.getRestaurants({ limit: 500 });
setRestaurants(data);
} else {
try {
const favs = await api.getMyFavorites();
setRestaurants(favs);
setShowFavorites(true);
setShowMyReviews(false);
setMyReviews([]);
setSelected(null);
setShowDetail(false);
} catch { /* ignore */ }
}
};
const handleToggleMyReviews = async () => {
if (showMyReviews) {
setShowMyReviews(false);
setMyReviews([]);
setMyMemos([]);
} else {
try {
const [reviews, memos] = await Promise.all([
api.getMyReviews(),
api.getMyMemos(),
]);
setMyReviews(reviews);
setMyMemos(memos);
setShowMyReviews(true);
setShowFavorites(false);
setSelected(null);
setShowDetail(false);
} catch { /* ignore */ }
}
};
// Desktop sidebar: shows detail inline
const sidebarContent = showMyReviews ? (
<MyReviewsList
reviews={myReviews}
memos={myMemos}
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
onSelectRestaurant={async (restaurantId) => {
try {
const r = await api.getRestaurant(restaurantId);
handleSelectRestaurant(r);
setShowMyReviews(false);
setMyReviews([]);
} catch { /* ignore */ }
}}
/>
) : showDetail && selected ? (
<RestaurantDetail
restaurant={selected}
onClose={handleCloseDetail}
/>
) : (
<RestaurantList
restaurants={filteredRestaurants}
selectedId={selected?.id}
onSelect={handleSelectRestaurant}
loading={loading}
keyPrefix="d-"
/>
);
// Mobile list: always shows list (detail goes to bottom sheet)
const mobileListContent = showMyReviews ? (
<MyReviewsList
reviews={myReviews}
memos={myMemos}
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
onSelectRestaurant={async (restaurantId) => {
try {
const r = await api.getRestaurant(restaurantId);
handleSelectRestaurant(r);
setShowMyReviews(false);
setMyReviews([]);
} catch { /* ignore */ }
}}
/>
) : (
<RestaurantList
restaurants={filteredRestaurants}
selectedId={selected?.id}
onSelect={handleSelectRestaurant}
loading={loading}
keyPrefix="m-"
/>
);
return (
<div className="h-screen flex flex-col bg-background">
{/* ── Header row 1: Logo + User ── */}
<header className="bg-surface/80 backdrop-blur-md border-b dark:border-gray-800 shrink-0">
<div className="px-5 py-3 flex items-center justify-between">
<button onClick={handleReset} className="shrink-0">
<img src="/logo-80h.png" alt="Tasteby" className="h-7 md:h-8" />
</button>
{/* Desktop: search + filters */}
<div className="hidden md:flex flex-col gap-2 mx-6 flex-1 min-w-0">
{/* Row 1: Search + actions */}
<div className="flex items-center gap-2">
<div className="w-80 shrink-0">
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
</div>
<button
onClick={handleReset}
className="p-1.5 text-gray-500 dark:text-gray-500 hover:text-brand-500 dark:hover:text-brand-400 transition-colors"
title="초기화"
>
<Icon name="refresh" size={18} />
</button>
<span className="text-xs text-gray-500 dark:text-gray-500 tabular-nums">
{filteredRestaurants.length}
</span>
<div className="w-px h-4 bg-gray-200 dark:bg-gray-700" />
<button
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
viewMode === "map"
? "bg-blue-50 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400"
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-blue-300 hover:text-blue-500"
}`}
>
<Icon name={viewMode === "map" ? "map" : "list"} size={14} className="mr-0.5" />{viewMode === "map" ? "지도우선" : "목록우선"}
</button>
{user && (
<>
<button
onClick={handleToggleFavorites}
className={`px-2.5 py-1 text-xs rounded-lg 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-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-rose-300 hover:text-rose-500"
}`}
>
<Icon name="favorite" size={14} filled={showFavorites} className="mr-0.5" />{showFavorites ? "내 찜" : "찜"}
</button>
<button
onClick={handleToggleMyReviews}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
showMyReviews
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400"
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-brand-300 hover:text-brand-500"
}`}
>
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
</button>
</>
)}
<div className="flex-1" />
{/* Desktop user area */}
{authLoading ? null : user ? (
<div className="flex items-center gap-2 shrink-0">
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-7 h-7 rounded-full border border-gray-200" />
) : (
<div className="w-7 h-7 rounded-full bg-brand-100 text-brand-700 flex items-center justify-center text-xs font-semibold border border-brand-200">
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
</div>
)}
<span className="text-xs font-medium text-gray-600 dark:text-gray-300 max-w-[80px] truncate">
{user.nickname || user.email}
</span>
<button
onClick={logout}
className="px-2 py-0.5 text-[10px] text-gray-500 dark:text-gray-500 border border-gray-200 dark:border-gray-700 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 transition-colors"
>
</button>
</div>
) : (
<div className="shrink-0">
<LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
</div>
)}
</div>
{/* Row 2: Channel cards (toggle filter) */}
{!isSearchResult && channels.length > 0 && (
<div ref={dd.ref} onMouseDown={dd.onMouseDown} onMouseMove={dd.onMouseMove} onMouseUp={dd.onMouseUp} onMouseLeave={dd.onMouseLeave} onClickCapture={dd.onClickCapture} style={dd.style} className="overflow-x-auto scrollbar-hide select-none">
<div className="flex gap-2">
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => {
setChannelFilter(channelFilter === ch.channel_name ? "" : ch.channel_name);
setSelected(null);
setShowDetail(false);
}}
className={`shrink-0 flex items-center gap-2 rounded-lg px-3 py-1.5 border transition-all text-left ${
channelFilter === ch.channel_name
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-brand-200 dark:hover:border-brand-800"
}`}
style={{ width: "200px" }}
>
<Icon name="play_circle" size={16} filled className="shrink-0 text-red-500" />
<div className="min-w-0 flex-1">
<p className={`text-xs font-semibold truncate ${
channelFilter === ch.channel_name ? "text-brand-600 dark:text-brand-400" : "dark:text-gray-200"
}`}>{ch.channel_name}</p>
{ch.description && <p className="text-[10px] text-gray-500 dark:text-gray-500 truncate">{ch.description}</p>}
</div>
</button>
))}
</div>
</div>
)}
{/* Row 3: Filters — grouped by category */}
<div className="flex items-center gap-1.5 text-xs">
{/* 음식 필터 그룹 */}
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-800/50 rounded-lg px-2 py-1">
<span className="text-gray-500 dark:text-gray-500 text-[10px] font-medium shrink-0"></span>
<select
value={cuisineFilter}
onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
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}`}>
&nbsp;&nbsp;{item}
</option>
))}
</optgroup>
))}
</select>
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
<select
value={priceFilter}
onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
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>
{(cuisineFilter || priceFilter) && (
<button
onClick={() => { setCuisineFilter(""); setPriceFilter(""); }}
className="text-gray-400 hover:text-brand-500 transition-colors"
title="음식 필터 초기화"
>
<Icon name="close" size={12} />
</button>
)}
</div>
{/* 지역 필터 그룹 */}
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-800/50 rounded-lg px-2 py-1">
<span className="text-gray-500 dark:text-gray-500 text-[10px] font-medium shrink-0"></span>
<select
value={countryFilter}
onChange={(e) => handleCountryChange(e.target.value)}
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
countryFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
>
<option value=""></option>
{countries.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
{countryFilter && cities.length > 0 && (
<>
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
<select
value={cityFilter}
onChange={(e) => handleCityChange(e.target.value)}
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
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>
</>
)}
{cityFilter && districts.length > 0 && (
<>
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
<select
value={districtFilter}
onChange={(e) => handleDistrictChange(e.target.value)}
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
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>
</>
)}
{countryFilter && (
<button
onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }}
className="text-gray-400 hover:text-brand-500 transition-colors"
title="지역 필터 초기화"
>
<Icon name="close" size={12} />
</button>
)}
</div>
{/* 필터 전체 해제 */}
{(channelFilter || cuisineFilter || priceFilter || countryFilter) && (
<button
onClick={() => {
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
setRegionFlyTo(null);
}}
className="flex items-center gap-1 rounded-lg px-2 py-1 bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500 transition-colors"
>
<Icon name="close" size={12} />
<span></span>
</button>
)}
{/* 내위치 토글 */}
<button
onClick={() => {
const next = !boundsFilterOn;
setBoundsFilterOn(next);
if (next) {
// 내위치 ON 시 다른 필터 초기화
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
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: 15 }); },
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
{ timeout: 5000 },
);
} else {
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
}
}
}}
className={`flex items-center gap-1 rounded-lg px-2 py-1 transition-colors ${
boundsFilterOn
? "bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400"
: "bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500"
}`}
title="내 위치 주변 식당만 표시"
>
<Icon name="location_on" size={14} />
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
</button>
</div>
</div>
{/* User area (mobile only - desktop moved to Row 1) */}
<div className="shrink-0 flex items-center gap-2 ml-auto md:hidden">
{user && (
<>
<button
onClick={handleToggleFavorites}
className={`px-2 py-0.5 text-[10px] 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-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
}`}
>
<Icon name="favorite" size={14} filled={showFavorites} className="mr-0.5" />{showFavorites ? "찜" : "찜"}
</button>
<button
onClick={handleToggleMyReviews}
className={`px-2 py-0.5 text-[10px] rounded-full border transition-colors ${
showMyReviews
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400"
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
}`}
>
{showMyReviews ? "✎ 리뷰" : "✎ 리뷰"}
</button>
</>
)}
{authLoading ? null : user ? (
<div className="flex items-center">
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-7 h-7 rounded-full border border-gray-200" />
) : (
<div className="w-7 h-7 rounded-full bg-brand-100 text-brand-700 flex items-center justify-center text-xs font-semibold border border-brand-200">
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
</div>
)}
</div>
) : (
<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 ${mobileTab === "favorites" || mobileTab === "profile" ? "hidden" : ""}`}>
{/* Row 1: Search */}
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
{/* Channel cards - toggle filter */}
{(mobileTab === "home" || mobileTab === "list" || mobileTab === "nearby") && !isSearchResult && channels.length > 0 && (
<div ref={dm.ref} onMouseDown={dm.onMouseDown} onMouseMove={dm.onMouseMove} onMouseUp={dm.onMouseUp} onMouseLeave={dm.onMouseLeave} onClickCapture={dm.onClickCapture} style={dm.style} className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide select-none">
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => {
setChannelFilter(channelFilter === ch.channel_name ? "" : ch.channel_name);
setSelected(null);
setShowDetail(false);
}}
className={`shrink-0 rounded-xl px-3 py-2 text-left border transition-all ${
channelFilter === ch.channel_name
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
}`}
style={{ minWidth: "140px", maxWidth: "170px" }}
>
<div className="flex items-center gap-1.5">
<Icon name="play_circle" size={14} filled className="shrink-0 text-red-500" />
<p className={`text-xs font-semibold truncate ${
channelFilter === ch.channel_name ? "text-brand-600 dark:text-brand-400" : "dark:text-gray-200"
}`}>{ch.channel_name}</p>
</div>
{ch.description && <p className="text-[10px] text-gray-500 dark:text-gray-400 truncate mt-0.5">{ch.description}</p>}
{ch.tags && (
<div className="flex gap-1 mt-1 overflow-hidden">
{ch.tags.split(",").slice(0, 2).map((t) => (
<span key={t} className="text-[9px] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full whitespace-nowrap">{t.trim()}</span>
))}
</div>
)}
</button>
))}
</div>
)}
{/* Row 2: Filters - always visible, 2 lines */}
<div className="space-y-1.5">
{/* Line 1: 음식 장르 + 가격 + 결과수 */}
<div className="flex items-center gap-1.5 text-xs">
<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"}`} />
<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"}`} />
<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} />
</button>
)}
<span className="text-[10px] text-gray-400 ml-auto tabular-nums">{filteredRestaurants.length}</span>
</div>
{/* Line 2: 나라 + 시 + 구 + 내위치 */}
<div className="flex items-center gap-1.5 text-xs">
<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"}`} />
<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"
}`}
>
<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 && (
<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">
<Icon name="close" size={14} />
</button>
)}
<button
onClick={() => {
const next = !boundsFilterOn;
setBoundsFilterOn(next);
if (next) {
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
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: 15 }); },
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
{ timeout: 5000 },
);
} else {
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
}
}
}}
className={`flex items-center gap-0.5 rounded-lg px-2 py-1 border transition-colors ${
boundsFilterOn
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400"
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
}`}
>
<Icon name="location_on" size={12} />
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
</button>
</div>
</div>
</div>
</header>
{/* ── Body: Desktop = side-by-side, Mobile = stacked ── */}
{/* Desktop layout */}
<div className="hidden md:flex flex-1 overflow-hidden">
{viewMode === "map" ? (
<>
<aside className="w-80 bg-surface border-r dark:border-gray-800 overflow-y-auto shrink-0">
{sidebarContent}
</aside>
<main className="flex-1 relative">
<MapView
restaurants={filteredRestaurants}
selected={selected}
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">
{visits.today} · {visits.total.toLocaleString()}
</div>
)}
</main>
</>
) : (
<>
<aside className="flex-1 bg-surface overflow-y-auto">
{sidebarContent}
</aside>
<main className="w-[40%] shrink-0 relative border-l dark:border-gray-800">
<MapView
restaurants={filteredRestaurants}
selected={selected}
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">
{visits.today} · {visits.total.toLocaleString()}
</div>
)}
</main>
</>
)}
</div>
{/* Mobile layout */}
<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 relative overflow-hidden">
<MapView
restaurants={filteredRestaurants}
selected={selected}
onSelectRestaurant={handleSelectRestaurant}
onBoundsChanged={handleBoundsChanged}
flyTo={regionFlyTo}
onMyLocation={handleMyLocation}
activeChannel={channelFilter || undefined}
/>
<div className="absolute top-2 left-2 bg-surface/90 backdrop-blur-sm rounded-lg px-3 py-1.5 shadow-sm z-10">
<span className="text-xs font-medium text-brand-600 dark:text-brand-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>
) : mobileTab === "profile" ? (
/* 내정보 */
<div className="flex-1 overflow-y-auto bg-surface">
{!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="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-brand-100 text-brand-700 flex items-center justify-center text-lg font-semibold border border-brand-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}
memos={myMemos}
onClose={() => {}}
onSelectRestaurant={async (restaurantId) => {
try {
const r = await api.getRestaurant(restaurantId);
handleSelectRestaurant(r);
} catch { /* ignore */ }
}}
/>
)}
</div>
</div>
)}
</div>
) : (
/* 홈 / 식당 목록 / 찜: 리스트 표시 */
<div className="flex-1 overflow-y-auto bg-surface">
{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 */}
<BottomSheet open={showDetail && !!selected} onClose={handleCloseDetail}>
{selected && (
<RestaurantDetail restaurant={selected} onClose={handleCloseDetail} />
)}
</BottomSheet>
</div>
{/* ── Mobile Bottom Nav (fixed) ── */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t dark:border-gray-800 bg-surface safe-area-bottom">
<div className="flex items-stretch h-14">
{([
{ key: "home", label: "홈", iconName: "home" },
{ key: "list", label: "식당 목록", iconName: "restaurant_menu" },
{ key: "nearby", label: "내주변", iconName: "near_me" },
{ key: "favorites", label: "찜", iconName: "favorite" },
{ key: "profile", label: "내정보", iconName: "person" },
] as { key: "home" | "list" | "nearby" | "favorites" | "profile"; label: string; iconName: string }[]).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-brand-600 dark:text-brand-400"
: "text-gray-500 dark:text-gray-500"
}`}
>
<Icon name={tab.iconName} size={22} filled={mobileTab === tab.key} />
<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-surface/60 backdrop-blur-sm py-2.5 items-center justify-center gap-2 text-[11px] text-gray-500 dark:text-gray-500 group">
<div className="relative">
<img
src="/icon.jpg"
alt="SDJ Labs"
className="w-6 h-6 rounded-full border-2 border-brand-200 shadow-sm group-hover:scale-110 group-hover:rotate-12 transition-all duration-300"
/>
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-300 rounded-full animate-ping opacity-75" />
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-400 rounded-full" />
</div>
<span className="font-medium tracking-wide group-hover:text-gray-600 transition-colors">
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>
);
}