UX improvements: mobile bottom sheet, cuisine taxonomy, search enhancements
- Add BottomSheet component for Google Maps-style restaurant detail on mobile (3-snap drag: 40%/55%/92%, velocity-based close, backdrop overlay) - Mobile map mode now full-screen with bottom sheet overlay for details - Collapsible filter panel on mobile with active filter badge count - Standardized cuisine taxonomy (46 categories: 한식|국밥, 일식|스시 etc.) with LLM remap endpoint and admin UI button - Enhanced search: keyword search now includes foods_mentioned + video title - Search results include channels array for frontend filtering - Channel filter moved to frontend filteredRestaurants (not API-level) - LLM extraction prompt updated for pipe-delimited region + cuisine taxonomy - Vector rebuild endpoint with rich JSON chunks per restaurant - Geolocation-based auto region selection on page load - Desktop filters split into two clean rows Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GoogleLogin } from "@react-oauth/google";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Restaurant, Channel, Review } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import MapView from "@/components/MapView";
|
||||
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";
|
||||
|
||||
const CUISINE_GROUPS: { label: string; prefix: string }[] = [
|
||||
{ label: "한식", prefix: "한식" },
|
||||
{ label: "일식", prefix: "일식" },
|
||||
{ label: "중식", prefix: "중식" },
|
||||
{ label: "양식", prefix: "양식" },
|
||||
{ label: "아시아", prefix: "아시아" },
|
||||
{ label: "기타", prefix: "기타" },
|
||||
];
|
||||
|
||||
function matchCuisineGroup(cuisineType: string | null, group: string): boolean {
|
||||
if (!cuisineType) return false;
|
||||
const g = CUISINE_GROUPS.find((g) => g.label === group);
|
||||
if (!g) return false;
|
||||
return cuisineType.startsWith(g.prefix);
|
||||
}
|
||||
|
||||
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) 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.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();
|
||||
@@ -19,10 +136,60 @@ export default function Home() {
|
||||
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 [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 [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 [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
|
||||
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(() => {
|
||||
return restaurants.filter((r) => {
|
||||
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
|
||||
if (cuisineFilter && !matchCuisineGroup(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;
|
||||
});
|
||||
}, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds]);
|
||||
|
||||
// Set desktop default to map mode on mount
|
||||
useEffect(() => {
|
||||
if (window.innerWidth >= 768) setViewMode("map");
|
||||
}, []);
|
||||
|
||||
// Load channels + record visit on mount
|
||||
useEffect(() => {
|
||||
@@ -33,11 +200,35 @@ export default function Home() {
|
||||
// Load restaurants on mount and when channel filter changes
|
||||
useEffect(() => {
|
||||
api
|
||||
.getRestaurants({ limit: 200, channel: channelFilter || undefined })
|
||||
.getRestaurants({ limit: 500, channel: channelFilter || undefined })
|
||||
.then(setRestaurants)
|
||||
.catch(console.error);
|
||||
}, [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 matched = restaurants.filter((r) => {
|
||||
const p = parseRegion(r.region);
|
||||
return p && p.country === match.country && p.city === match.city;
|
||||
});
|
||||
setRegionFlyTo(computeFlyTo(matched));
|
||||
}
|
||||
},
|
||||
() => { /* user denied or error — do nothing */ },
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
}, [restaurants]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
async (query: string, mode: "keyword" | "semantic" | "hybrid") => {
|
||||
setLoading(true);
|
||||
@@ -64,13 +255,72 @@ export default function Home() {
|
||||
setShowDetail(false);
|
||||
}, []);
|
||||
|
||||
const handleBoundsChanged = useCallback((bounds: MapBounds) => {
|
||||
setMapBounds(bounds);
|
||||
}, []);
|
||||
|
||||
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: 200 })
|
||||
.getRestaurants({ limit: 500 })
|
||||
.then((data) => {
|
||||
setRestaurants(data);
|
||||
setSelected(null);
|
||||
@@ -83,7 +333,7 @@ export default function Home() {
|
||||
const handleToggleFavorites = async () => {
|
||||
if (showFavorites) {
|
||||
setShowFavorites(false);
|
||||
const data = await api.getRestaurants({ limit: 200, channel: channelFilter || undefined });
|
||||
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined });
|
||||
setRestaurants(data);
|
||||
} else {
|
||||
try {
|
||||
@@ -114,6 +364,7 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
// Desktop sidebar: shows detail inline
|
||||
const sidebarContent = showMyReviews ? (
|
||||
<MyReviewsList
|
||||
reviews={myReviews}
|
||||
@@ -134,7 +385,29 @@ export default function Home() {
|
||||
/>
|
||||
) : (
|
||||
<RestaurantList
|
||||
restaurants={restaurants}
|
||||
restaurants={filteredRestaurants}
|
||||
selectedId={selected?.id}
|
||||
onSelect={handleSelectRestaurant}
|
||||
/>
|
||||
);
|
||||
|
||||
// Mobile list: always shows list (detail goes to bottom sheet)
|
||||
const mobileListContent = showMyReviews ? (
|
||||
<MyReviewsList
|
||||
reviews={myReviews}
|
||||
onClose={() => { setShowMyReviews(false); setMyReviews([]); }}
|
||||
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}
|
||||
/>
|
||||
@@ -149,62 +422,136 @@ export default function Home() {
|
||||
Tasteby
|
||||
</button>
|
||||
|
||||
{/* Desktop: search inline */}
|
||||
<div className="hidden md:block flex-1 max-w-xl mx-4">
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
</div>
|
||||
|
||||
{/* Desktop: filters inline */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
setChannelFilter(e.target.value);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className="border rounded px-2 py-1.5 text-sm text-gray-600"
|
||||
>
|
||||
<option value="">전체 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
{ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
|
||||
showFavorites
|
||||
? "bg-red-50 border-red-300 text-red-600"
|
||||
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
{/* Desktop: search + filters — two rows */}
|
||||
<div className="hidden md:flex flex-col gap-1.5 mx-4">
|
||||
{/* Row 1: Search + dropdown filters */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-96 shrink-0">
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
</div>
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
setChannelFilter(e.target.value);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
>
|
||||
<option value="">전체 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
{ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
>
|
||||
<option value="">전체 장르</option>
|
||||
{CUISINE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={priceFilter}
|
||||
onChange={(e) => setPriceFilter(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
>
|
||||
<option value="">전체 가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={countryFilter}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
>
|
||||
<option value="">전체 나라</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
{countryFilter && cities.length > 0 && (
|
||||
<select
|
||||
value={cityFilter}
|
||||
onChange={(e) => handleCityChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
>
|
||||
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
|
||||
showMyReviews
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
<option value="">전체 시/도</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{cityFilter && districts.length > 0 && (
|
||||
<select
|
||||
value={districtFilter}
|
||||
onChange={(e) => handleDistrictChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm text-gray-600"
|
||||
>
|
||||
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
{restaurants.length}개
|
||||
</span>
|
||||
<option value="">전체 구/군</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{/* Row 2: Toggle buttons + count */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setBoundsFilterOn(!boundsFilterOn)}
|
||||
className={`px-2.5 py-1 text-sm border rounded transition-colors ${
|
||||
boundsFilterOn
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "hover:bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
title="지도 영역 내 식당만 표시"
|
||||
>
|
||||
{boundsFilterOn ? "📍 영역" : "📍"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
|
||||
className="px-2.5 py-1 text-sm border rounded transition-colors hover:bg-gray-100 text-gray-600"
|
||||
title={viewMode === "map" ? "리스트 우선" : "지도 우선"}
|
||||
>
|
||||
{viewMode === "map" ? "🗺" : "☰"}
|
||||
</button>
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
showFavorites
|
||||
? "bg-red-50 border-red-300 text-red-600"
|
||||
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
showMyReviews
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
{filteredRestaurants.length}개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-4 shrink-0 hidden md:block" />
|
||||
|
||||
{/* User area */}
|
||||
<div className="shrink-0">
|
||||
<div className="shrink-0 flex items-center gap-3 ml-auto">
|
||||
{authLoading ? null : user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{user.avatar_url ? (
|
||||
@@ -242,54 +589,160 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Header row 2 (mobile only): search + filters ── */}
|
||||
<div className="md:hidden px-4 pb-2 space-y-2">
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
setChannelFilter(e.target.value);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 shrink-0"
|
||||
{/* ── Header row 2 (mobile only): search + toolbar ── */}
|
||||
<div className="md:hidden px-4 pb-2 space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1">
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
|
||||
className={`px-2 py-1.5 text-xs border rounded transition-colors shrink-0 ${
|
||||
viewMode === "map"
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
<option value="">전체 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
{ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors shrink-0 ${
|
||||
showFavorites
|
||||
? "bg-red-50 border-red-300 text-red-600"
|
||||
: "border-gray-300 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors shrink-0 ${
|
||||
showMyReviews
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "border-gray-300 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-gray-400 shrink-0 ml-1">
|
||||
{restaurants.length}개
|
||||
{viewMode === "map" ? "🗺" : "☰"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||
className={`px-2 py-1.5 text-xs border rounded transition-colors shrink-0 relative ${
|
||||
showMobileFilters || channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{showMobileFilters ? "✕" : "▽"} 필터
|
||||
{!showMobileFilters && (channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn) && (
|
||||
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-blue-500 text-white rounded-full text-[9px] flex items-center justify-center">
|
||||
{[channelFilter, cuisineFilter, priceFilter, countryFilter, boundsFilterOn].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs text-gray-400 shrink-0">
|
||||
{filteredRestaurants.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Collapsible filter panel */}
|
||||
{showMobileFilters && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 space-y-2 border">
|
||||
{/* Dropdown filters */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
setChannelFilter(e.target.value);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
>
|
||||
<option value="">전체 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
{ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
>
|
||||
<option value="">전체 장르</option>
|
||||
{CUISINE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={priceFilter}
|
||||
onChange={(e) => setPriceFilter(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
>
|
||||
<option value="">전체 가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Region filters */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<select
|
||||
value={countryFilter}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
>
|
||||
<option value="">전체 나라</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
{countryFilter && cities.length > 0 && (
|
||||
<select
|
||||
value={cityFilter}
|
||||
onChange={(e) => handleCityChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
>
|
||||
<option value="">전체 시/도</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{cityFilter && districts.length > 0 && (
|
||||
<select
|
||||
value={districtFilter}
|
||||
onChange={(e) => handleDistrictChange(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 bg-white"
|
||||
>
|
||||
<option value="">전체 구/군</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{/* Toggle buttons */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setBoundsFilterOn(!boundsFilterOn)}
|
||||
className={`px-2 py-1 text-xs border rounded transition-colors ${
|
||||
boundsFilterOn
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "text-gray-600 bg-white"
|
||||
}`}
|
||||
>
|
||||
{boundsFilterOn ? "📍 영역 ON" : "📍 영역"}
|
||||
</button>
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||
showFavorites
|
||||
? "bg-red-50 border-red-300 text-red-600"
|
||||
: "border-gray-300 text-gray-600 bg-white"
|
||||
}`}
|
||||
>
|
||||
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||
showMyReviews
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "border-gray-300 text-gray-600 bg-white"
|
||||
}`}
|
||||
>
|
||||
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -297,42 +750,101 @@ export default function Home() {
|
||||
|
||||
{/* Desktop layout */}
|
||||
<div className="hidden md:flex flex-1 overflow-hidden">
|
||||
<aside className="w-80 bg-white border-r overflow-y-auto shrink-0">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
<main className="flex-1 relative">
|
||||
<MapView
|
||||
restaurants={restaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{viewMode === "map" ? (
|
||||
<>
|
||||
<aside className="w-80 bg-white border-r overflow-y-auto shrink-0">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
<main className="flex-1 relative">
|
||||
<MapView
|
||||
restaurants={filteredRestaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<aside className="flex-1 bg-white overflow-y-auto">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
<main className="w-[40%] shrink-0 relative border-l">
|
||||
<MapView
|
||||
restaurants={filteredRestaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile layout */}
|
||||
<div className="md:hidden flex-1 flex flex-col overflow-hidden">
|
||||
{/* Map: fixed height */}
|
||||
<div className="h-[40vh] shrink-0 relative">
|
||||
<MapView
|
||||
restaurants={restaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded z-10">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
{viewMode === "map" ? (
|
||||
<>
|
||||
<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-black/40 text-white text-[10px] px-2 py-0.5 rounded z-10">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 bg-white overflow-y-auto">
|
||||
{mobileListContent}
|
||||
{/* Scroll-down hint to reveal map */}
|
||||
<div className="flex flex-col items-center py-4 text-gray-300">
|
||||
<span className="text-lg">▼</span>
|
||||
<span className="text-[10px]">아래로 스크롤하면 지도</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[35vh] shrink-0 relative border-t">
|
||||
<MapView
|
||||
restaurants={filteredRestaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded z-10">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile Bottom Sheet for restaurant detail */}
|
||||
<BottomSheet open={showDetail && !!selected} onClose={handleCloseDetail}>
|
||||
{selected && (
|
||||
<RestaurantDetail restaurant={selected} onClose={handleCloseDetail} />
|
||||
)}
|
||||
</div>
|
||||
{/* List/Detail: scrollable below */}
|
||||
<div className="flex-1 bg-white border-t overflow-y-auto">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user