- 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>
248 lines
9.0 KiB
TypeScript
248 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
APIProvider,
|
|
Map,
|
|
AdvancedMarker,
|
|
InfoWindow,
|
|
useMap,
|
|
} from "@vis.gl/react-google-maps";
|
|
import type { Restaurant } from "@/lib/api";
|
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
|
|
|
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
|
|
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
|
|
|
|
// Channel color palette
|
|
const CHANNEL_COLORS = [
|
|
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
|
|
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
|
|
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
|
|
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
|
|
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
|
|
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
|
|
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
|
|
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
|
|
];
|
|
|
|
function getChannelColorMap(restaurants: Restaurant[]) {
|
|
const channels = new Set<string>();
|
|
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
|
|
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
|
|
let i = 0;
|
|
for (const ch of channels) {
|
|
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
|
|
i++;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
export interface MapBounds {
|
|
north: number;
|
|
south: number;
|
|
east: number;
|
|
west: number;
|
|
}
|
|
|
|
export interface FlyTo {
|
|
lat: number;
|
|
lng: number;
|
|
zoom?: number;
|
|
}
|
|
|
|
interface MapViewProps {
|
|
restaurants: Restaurant[];
|
|
selected?: Restaurant | null;
|
|
onSelectRestaurant?: (r: Restaurant) => void;
|
|
onBoundsChanged?: (bounds: MapBounds) => void;
|
|
flyTo?: FlyTo | null;
|
|
}
|
|
|
|
function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) {
|
|
const map = useMap();
|
|
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
|
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Report bounds on idle (debounced)
|
|
useEffect(() => {
|
|
if (!map) return;
|
|
const listener = map.addListener("idle", () => {
|
|
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
|
boundsTimerRef.current = setTimeout(() => {
|
|
const b = map.getBounds();
|
|
if (b && onBoundsChanged) {
|
|
const ne = b.getNorthEast();
|
|
const sw = b.getSouthWest();
|
|
onBoundsChanged({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
|
}
|
|
}, 300);
|
|
});
|
|
return () => {
|
|
google.maps.event.removeListener(listener);
|
|
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
|
};
|
|
}, [map, onBoundsChanged]);
|
|
|
|
const handleMarkerClick = useCallback(
|
|
(r: Restaurant) => {
|
|
setInfoTarget(r);
|
|
onSelectRestaurant?.(r);
|
|
},
|
|
[onSelectRestaurant]
|
|
);
|
|
|
|
// Fly to a specific location (region filter)
|
|
useEffect(() => {
|
|
if (!map || !flyTo) return;
|
|
map.panTo({ lat: flyTo.lat, lng: flyTo.lng });
|
|
if (flyTo.zoom) map.setZoom(flyTo.zoom);
|
|
}, [map, flyTo]);
|
|
|
|
// Pan and zoom to selected restaurant
|
|
useEffect(() => {
|
|
if (!map || !selected) return;
|
|
map.panTo({ lat: selected.latitude, lng: selected.longitude });
|
|
map.setZoom(16);
|
|
setInfoTarget(selected);
|
|
}, [map, selected]);
|
|
|
|
return (
|
|
<>
|
|
{restaurants.map((r) => {
|
|
const isSelected = selected?.id === r.id;
|
|
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
|
const chColor = r.channels?.[0] ? channelColors[r.channels[0]] : CHANNEL_COLORS[0];
|
|
const c = chColor || CHANNEL_COLORS[0];
|
|
return (
|
|
<AdvancedMarker
|
|
key={r.id}
|
|
position={{ lat: r.latitude, lng: r.longitude }}
|
|
onClick={() => handleMarkerClick(r)}
|
|
zIndex={isSelected ? 1000 : 1}
|
|
>
|
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}>
|
|
<div
|
|
style={{
|
|
padding: "4px 8px",
|
|
backgroundColor: isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg,
|
|
color: isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text,
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
borderRadius: 6,
|
|
border: isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`,
|
|
boxShadow: isSelected
|
|
? "0 2px 8px rgba(37,99,235,0.4)"
|
|
: `0 1px 4px ${c.border}40`,
|
|
whiteSpace: "nowrap",
|
|
maxWidth: 120,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
textDecoration: isClosed ? "line-through" : "none",
|
|
}}
|
|
>
|
|
<span style={{ marginRight: 3 }}>{getCuisineIcon(r.cuisine_type)}</span>
|
|
{r.name}
|
|
</div>
|
|
<div
|
|
style={{
|
|
width: 0,
|
|
height: 0,
|
|
borderLeft: "6px solid transparent",
|
|
borderRight: "6px solid transparent",
|
|
borderTop: isSelected ? "6px solid #1d4ed8" : `6px solid ${c.arrow}`,
|
|
marginTop: -1,
|
|
}}
|
|
/>
|
|
</div>
|
|
</AdvancedMarker>
|
|
);
|
|
})}
|
|
|
|
{infoTarget && (
|
|
<InfoWindow
|
|
position={{ lat: infoTarget.latitude, lng: infoTarget.longitude }}
|
|
onCloseClick={() => setInfoTarget(null)}
|
|
>
|
|
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-bold text-base" style={{ color: "#171717" }}>{getCuisineIcon(infoTarget.cuisine_type)} {infoTarget.name}</h3>
|
|
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
|
|
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold">폐업</span>
|
|
)}
|
|
{infoTarget.business_status === "CLOSED_TEMPORARILY" && (
|
|
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold">임시휴업</span>
|
|
)}
|
|
</div>
|
|
{infoTarget.rating && (
|
|
<p className="text-xs mt-0.5">
|
|
<span className="text-yellow-500">★</span> {infoTarget.rating}
|
|
{infoTarget.rating_count && (
|
|
<span className="text-gray-400 ml-1">({infoTarget.rating_count.toLocaleString()})</span>
|
|
)}
|
|
</p>
|
|
)}
|
|
{infoTarget.cuisine_type && (
|
|
<p className="text-sm text-gray-600">{infoTarget.cuisine_type}</p>
|
|
)}
|
|
{infoTarget.address && (
|
|
<p className="text-xs text-gray-500 mt-1">{infoTarget.address}</p>
|
|
)}
|
|
{infoTarget.price_range && (
|
|
<p className="text-xs text-gray-500">{infoTarget.price_range}</p>
|
|
)}
|
|
{infoTarget.phone && (
|
|
<p className="text-xs text-gray-500">{infoTarget.phone}</p>
|
|
)}
|
|
<button
|
|
onClick={() => onSelectRestaurant?.(infoTarget)}
|
|
className="mt-2 text-sm text-blue-600 hover:underline"
|
|
>
|
|
상세 보기
|
|
</button>
|
|
</div>
|
|
</InfoWindow>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) {
|
|
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
|
const channelNames = useMemo(() => Object.keys(channelColors), [channelColors]);
|
|
|
|
return (
|
|
<APIProvider apiKey={API_KEY}>
|
|
<Map
|
|
defaultCenter={SEOUL_CENTER}
|
|
defaultZoom={12}
|
|
mapId="tasteby-map"
|
|
className="h-full w-full"
|
|
colorScheme="LIGHT"
|
|
>
|
|
<MapContent
|
|
restaurants={restaurants}
|
|
selected={selected}
|
|
onSelectRestaurant={onSelectRestaurant}
|
|
onBoundsChanged={onBoundsChanged}
|
|
flyTo={flyTo}
|
|
/>
|
|
</Map>
|
|
{channelNames.length > 1 && (
|
|
<div className="absolute top-2 left-2 bg-white/90 rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10">
|
|
{channelNames.map((ch) => (
|
|
<div key={ch} className="flex items-center gap-1">
|
|
<span
|
|
className="inline-block w-2.5 h-2.5 rounded-full border"
|
|
style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }}
|
|
/>
|
|
<span className="text-gray-700">{ch}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</APIProvider>
|
|
);
|
|
}
|