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:
116
frontend/src/components/BottomSheet.tsx
Normal file
116
frontend/src/components/BottomSheet.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface BottomSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SNAP_POINTS = { PEEK: 0.4, HALF: 0.55, FULL: 0.92 };
|
||||
const VELOCITY_THRESHOLD = 0.5;
|
||||
|
||||
export default function BottomSheet({ open, onClose, children }: BottomSheetProps) {
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(SNAP_POINTS.PEEK);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragState = useRef({ startY: 0, startH: 0, lastY: 0, lastTime: 0 });
|
||||
|
||||
// Reset to peek when opened
|
||||
useEffect(() => {
|
||||
if (open) setHeight(SNAP_POINTS.PEEK);
|
||||
}, [open]);
|
||||
|
||||
const snapTo = useCallback((h: number, velocity: number) => {
|
||||
// If fast downward swipe, close
|
||||
if (velocity > VELOCITY_THRESHOLD && h < SNAP_POINTS.HALF) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// Snap to nearest point
|
||||
const points = [SNAP_POINTS.PEEK, SNAP_POINTS.HALF, SNAP_POINTS.FULL];
|
||||
let best = points[0];
|
||||
let bestDist = Math.abs(h - best);
|
||||
for (const p of points) {
|
||||
const d = Math.abs(h - p);
|
||||
if (d < bestDist) { best = p; bestDist = d; }
|
||||
}
|
||||
// If dragged below peek, close
|
||||
if (h < SNAP_POINTS.PEEK * 0.6) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setHeight(best);
|
||||
}, [onClose]);
|
||||
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
// Don't intercept if scrolling inside content that has scrollable area
|
||||
const content = contentRef.current;
|
||||
if (content && content.scrollTop > 0 && height >= SNAP_POINTS.FULL - 0.05) return;
|
||||
|
||||
const y = e.touches[0].clientY;
|
||||
dragState.current = { startY: y, startH: height, lastY: y, lastTime: Date.now() };
|
||||
setDragging(true);
|
||||
}, [height]);
|
||||
|
||||
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!dragging) return;
|
||||
const y = e.touches[0].clientY;
|
||||
const vh = window.innerHeight;
|
||||
const deltaRatio = (dragState.current.startY - y) / vh;
|
||||
const newH = Math.max(0.1, Math.min(SNAP_POINTS.FULL, dragState.current.startH + deltaRatio));
|
||||
setHeight(newH);
|
||||
dragState.current.lastY = y;
|
||||
dragState.current.lastTime = Date.now();
|
||||
}, [dragging]);
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
if (!dragging) return;
|
||||
setDragging(false);
|
||||
const dt = (Date.now() - dragState.current.lastTime) / 1000 || 0.1;
|
||||
const dy = (dragState.current.startY - dragState.current.lastY) / window.innerHeight;
|
||||
const velocity = -dy / dt; // positive = downward
|
||||
snapTo(height, velocity);
|
||||
}, [dragging, height, snapTo]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 md:hidden"
|
||||
style={{ opacity: Math.min(1, (height - 0.2) * 2) }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-white rounded-t-2xl shadow-2xl"
|
||||
style={{
|
||||
height: `${height * 100}vh`,
|
||||
transition: dragging ? "none" : "height 0.3s cubic-bezier(0.2, 0, 0, 1)",
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Handle bar */}
|
||||
<div className="flex justify-center pt-2 pb-1 shrink-0 cursor-grab">
|
||||
<div className="w-10 h-1 bg-gray-300 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex-1 overflow-y-auto overscroll-contain"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
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 || "";
|
||||
@@ -37,16 +38,52 @@ function getChannelColorMap(restaurants: Restaurant[]) {
|
||||
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 }: MapViewProps) {
|
||||
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) => {
|
||||
@@ -56,6 +93,13 @@ function MapContent({ restaurants, selected, onSelectRestaurant }: MapViewProps)
|
||||
[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;
|
||||
@@ -98,6 +142,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant }: MapViewProps)
|
||||
textDecoration: isClosed ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 3 }}>{getCuisineIcon(r.cuisine_type)}</span>
|
||||
{r.name}
|
||||
</div>
|
||||
<div
|
||||
@@ -122,7 +167,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant }: MapViewProps)
|
||||
>
|
||||
<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" }}>{infoTarget.name}</h3>
|
||||
<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>
|
||||
)}
|
||||
@@ -163,7 +208,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant }: MapViewProps)
|
||||
);
|
||||
}
|
||||
|
||||
export default function MapView({ restaurants, selected, onSelectRestaurant }: MapViewProps) {
|
||||
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) {
|
||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||
const channelNames = useMemo(() => Object.keys(channelColors), [channelColors]);
|
||||
|
||||
@@ -180,6 +225,8 @@ export default function MapView({ restaurants, selected, onSelectRestaurant }: M
|
||||
restaurants={restaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={onSelectRestaurant}
|
||||
onBoundsChanged={onBoundsChanged}
|
||||
flyTo={flyTo}
|
||||
/>
|
||||
</Map>
|
||||
{channelNames.length > 1 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
|
||||
interface RestaurantListProps {
|
||||
restaurants: Restaurant[];
|
||||
@@ -31,7 +32,10 @@ export default function RestaurantList({
|
||||
selectedId === r.id ? "bg-blue-50 border-l-2 border-blue-500" : ""
|
||||
}`}
|
||||
>
|
||||
<h4 className="font-medium text-sm">{r.name}</h4>
|
||||
<h4 className="font-medium text-sm">
|
||||
<span className="mr-1">{getCuisineIcon(r.cuisine_type)}</span>
|
||||
{r.name}
|
||||
</h4>
|
||||
<div className="flex gap-2 mt-1 text-xs text-gray-500">
|
||||
{r.cuisine_type && <span>{r.cuisine_type}</span>}
|
||||
{r.region && <span>{r.region}</span>}
|
||||
|
||||
Reference in New Issue
Block a user