"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 } 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 { getCuisineIcon } from "@/lib/cuisine-icons"; 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>>(); for (const r of restaurants) { const p = parseRegion(r.region); if (!p || !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(); for (const r of restaurants) { const p = parseRegion(r.region); if (!p || !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([]); const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); const [showDetail, setShowDetail] = useState(false); const [channels, setChannels] = useState([]); 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(null); const [boundsFilterOn, setBoundsFilterOn] = useState(false); const [countryFilter, setCountryFilter] = useState(""); const [cityFilter, setCityFilter] = useState(""); const [districtFilter, setDistrictFilter] = useState(""); const [regionFlyTo, setRegionFlyTo] = useState(null); const [showFavorites, setShowFavorites] = useState(false); 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]); 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; 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 && mapBounds) { if (r.latitude < mapBounds.south || r.latitude > mapBounds.north) return false; 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, userLoc]); // 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 restaurants on mount and when channel filter changes useEffect(() => { setLoading(true); api .getRestaurants({ limit: 500, channel: channelFilter || undefined }) .then(setRestaurants) .catch(console.error) .finally(() => setLoading(false)); }, [channelFilter]); // 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); } 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: 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(""); setDistrictFilter(""); 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); 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, 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); const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined }); 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([]); } else { try { const reviews = await api.getMyReviews(); setMyReviews(reviews); setShowMyReviews(true); setShowFavorites(false); setSelected(null); setShowDetail(false); } catch { /* ignore */ } } }; // Desktop sidebar: shows detail inline const sidebarContent = showMyReviews ? ( { setShowMyReviews(false); setMyReviews([]); }} onSelectRestaurant={async (restaurantId) => { try { const r = await api.getRestaurant(restaurantId); handleSelectRestaurant(r); setShowMyReviews(false); setMyReviews([]); } catch { /* ignore */ } }} /> ) : showDetail && selected ? ( ) : ( ); // Mobile list: always shows list (detail goes to bottom sheet) const mobileListContent = showMyReviews ? ( { setShowMyReviews(false); setMyReviews([]); }} onSelectRestaurant={async (restaurantId) => { try { const r = await api.getRestaurant(restaurantId); handleSelectRestaurant(r); setShowMyReviews(false); setMyReviews([]); } catch { /* ignore */ } }} /> ) : ( ); return (
{/* ── Header row 1: Logo + User ── */}
{/* Desktop: search + filters — two rows */}
{/* Row 1: Search + dropdown filters */}
{/* Row 2: Region filters + Toggle buttons + count */}
{countryFilter && cities.length > 0 && ( )} {cityFilter && districts.length > 0 && ( )}
{user && ( <> )} {filteredRestaurants.length}개
{/* User area */}
{authLoading ? null : user ? (
{user.avatar_url ? ( ) : (
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
)} {user.nickname || user.email}
) : ( login(credential).catch(console.error)} /> )}
{/* ── Header row 2 (mobile only): search + toolbar ── */}
{/* Row 1: Search */} {/* Row 2: Toolbar */}
{filteredRestaurants.length}개
{/* Collapsible filter panel */} {showMobileFilters && (
{/* Dropdown filters */}
{/* Region filters */}
{countryFilter && cities.length > 0 && ( )} {cityFilter && districts.length > 0 && ( )}
{/* Toggle buttons */}
)}
{/* ── Body: Desktop = side-by-side, Mobile = stacked ── */} {/* Desktop layout */}
{viewMode === "map" ? ( <>
{visits && (
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
)}
) : ( <>
{visits && (
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
)}
)}
{/* Mobile layout */}
{/* Tab content — takes all remaining space above fixed nav */} {mobileTab === "nearby" ? ( /* 내주변: 지도 + 리스트 분할, 영역필터 ON */
내 주변 {filteredRestaurants.length}개
{visits && (
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
)}
{mobileListContent}
) : mobileTab === "profile" ? ( /* 내정보 */
{!user ? (

로그인하고 리뷰와 찜 목록을 관리하세요

{ if (res.credential) login(res.credential).catch(console.error); }} onError={() => console.error("Google login failed")} size="large" text="signin_with" />
) : (
{/* 프로필 헤더 */}
{user.avatar_url ? ( ) : (
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
)}

{user.nickname || user.email}

{user.nickname && user.email && (

{user.email}

)}
{/* 내 리뷰 */}

내 리뷰

{myReviews.length === 0 ? (

작성한 리뷰가 없습니다

) : ( {}} onSelectRestaurant={async (restaurantId) => { try { const r = await api.getRestaurant(restaurantId); handleSelectRestaurant(r); } catch { /* ignore */ } }} /> )}
)}
) : ( /* 홈 / 식당 목록 / 찜: 리스트 표시 */
{mobileTab === "favorites" && !user ? (

로그인하고 찜 목록을 확인하세요

{ if (res.credential) login(res.credential).catch(console.error); }} onError={() => console.error("Google login failed")} size="large" text="signin_with" />
) : ( mobileListContent )}
)} {/* Mobile Bottom Sheet for restaurant detail */} {selected && ( )}
{/* ── Mobile Bottom Nav (fixed) ── */} {/* Desktop Footer */}
); }