UI/UX 개선: 모바일 네비게이션, 로그인 모달, 지도 기능, 캐치테이블 연동

- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가
- 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능)
- 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가
- 채널 필터 시 해당 채널만 마커/범례 표시
- 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴
- 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용)
- 테이블링/캐치테이블 버튼 UI 차별화
- Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-11 00:49:16 +09:00
parent 58c0f972e2
commit cdee37e341
23 changed files with 1465 additions and 325 deletions

View File

@@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { createPortal } from "react-dom";
import { GoogleLogin } from "@react-oauth/google";
interface LoginMenuProps {
onGoogleSuccess: (credential: string) => void;
}
export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
className="px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-orange-600 dark:hover:text-orange-400 border border-gray-300 dark:border-gray-600 hover:border-orange-400 dark:hover:border-orange-500 rounded-lg transition-colors"
>
</button>
{open && createPortal(
<div
className="fixed inset-0 flex items-center justify-center bg-black/40 backdrop-blur-sm"
style={{ zIndex: 99999 }}
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
>
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-xs space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold dark:text-gray-100"></h3>
<button
onClick={() => setOpen(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none"
>
</button>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500"> </p>
<div className="flex flex-col items-center gap-3">
<GoogleLogin
onSuccess={(res) => {
if (res.credential) {
onGoogleSuccess(res.credential);
setOpen(false);
}
}}
onError={() => console.error("Google login failed")}
size="large"
width="260"
text="signin_with"
/>
</div>
</div>
</div>,
document.body,
)}
</>
);
}

View File

@@ -57,33 +57,14 @@ interface MapViewProps {
onSelectRestaurant?: (r: Restaurant) => void;
onBoundsChanged?: (bounds: MapBounds) => void;
flyTo?: FlyTo | null;
onMyLocation?: () => void;
activeChannel?: string;
}
function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) {
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
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) => {
@@ -113,7 +94,8 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
{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 chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
const c = chColor || CHANNEL_COLORS[0];
return (
<AdvancedMarker
@@ -208,29 +190,56 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
);
}
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) {
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) {
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const channelNames = useMemo(() => Object.keys(channelColors), [channelColors]);
const channelNames = useMemo(() => {
const names = Object.keys(channelColors);
if (activeChannel) return names.filter((n) => n === activeChannel);
return names;
}, [channelColors, activeChannel]);
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => {
if (!onBoundsChanged) return;
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
boundsTimerRef.current = setTimeout(() => {
const { north, south, east, west } = ev.detail.bounds;
onBoundsChanged({ north, south, east, west });
}, 150);
}, [onBoundsChanged]);
return (
<APIProvider apiKey={API_KEY}>
<Map
defaultCenter={SEOUL_CENTER}
defaultZoom={12}
defaultZoom={13}
mapId="tasteby-map"
className="h-full w-full"
colorScheme="LIGHT"
mapTypeControl={false}
fullscreenControl={false}
onCameraChanged={handleCameraChanged}
>
<MapContent
restaurants={restaurants}
selected={selected}
onSelectRestaurant={onSelectRestaurant}
onBoundsChanged={onBoundsChanged}
flyTo={flyTo}
activeChannel={activeChannel}
/>
</Map>
{channelNames.length > 1 && (
{onMyLocation && (
<button
onClick={onMyLocation}
className="absolute top-2 right-2 w-9 h-9 bg-white dark:bg-gray-900 rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-colors z-10"
title="내 위치"
>
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current">
<path d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm8.94 3A8.994 8.994 0 0013 3.06V1h-2v2.06A8.994 8.994 0 003.06 11H1v2h2.06A8.994 8.994 0 0011 20.94V23h2v-2.06A8.994 8.994 0 0020.94 13H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/>
</svg>
</button>
)}
{channelNames.length > 0 && (
<div className="absolute bottom-2 left-2 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm 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">

View File

@@ -84,6 +84,7 @@ export default function RestaurantDetail({
{restaurant.rating && (
<div className="flex items-center gap-2 text-sm">
<span className="text-blue-500 dark:text-blue-400 font-medium text-xs">Google</span>
<span className="text-yellow-500 dark:text-yellow-400">{"★".repeat(Math.round(restaurant.rating))}</span>
<span className="font-medium dark:text-gray-200">{restaurant.rating}</span>
{restaurant.rating_count && (
@@ -124,7 +125,7 @@ export default function RestaurantDetail({
{restaurant.google_place_id && (
<p>
<a
href={`https://www.google.com/maps/place/?q=place_id:${restaurant.google_place_id}`}
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline text-xs"
@@ -135,6 +136,30 @@ export default function RestaurantDetail({
)}
</div>
{restaurant.tabling_url && restaurant.tabling_url !== "NONE" && (
<a
href={restaurant.tabling_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-rose-500 hover:bg-rose-600 text-white rounded-lg text-sm font-semibold transition-colors"
>
<span>T</span>
<span> </span>
</a>
)}
{restaurant.catchtable_url && restaurant.catchtable_url !== "NONE" && (
<a
href={restaurant.catchtable_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-violet-500 hover:bg-violet-600 text-white rounded-lg text-sm font-semibold transition-colors"
>
<span>C</span>
<span> </span>
</a>
)}
<div>
<h3 className="font-semibold text-sm mb-2 dark:text-gray-200"> </h3>
{loading ? (
@@ -161,7 +186,8 @@ export default function RestaurantDetail({
<div key={v.video_id} className="border dark:border-gray-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
{v.channel_name && (
<span className="inline-block px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded text-[10px] font-medium">
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400 rounded text-[10px] font-semibold">
<span className="text-[9px]"></span>
{v.channel_name}
</span>
)}
@@ -175,8 +201,11 @@ export default function RestaurantDetail({
href={v.url}
target="_blank"
rel="noopener noreferrer"
className="block text-sm font-medium text-orange-600 dark:text-orange-400 hover:underline"
className="inline-flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-400 hover:underline"
>
<svg viewBox="0 0 24 24" className="w-4 h-4 flex-shrink-0 fill-current" aria-hidden="true">
<path d="M23.5 6.2c-.3-1-1-1.8-2-2.1C19.6 3.5 12 3.5 12 3.5s-7.6 0-9.5.6c-1 .3-1.7 1.1-2 2.1C0 8.1 0 12 0 12s0 3.9.5 5.8c.3 1 1 1.8 2 2.1 1.9.6 9.5.6 9.5.6s7.6 0 9.5-.6c1-.3 1.7-1.1 2-2.1.5-1.9.5-5.8.5-5.8s0-3.9-.5-5.8zM9.5 15.5V8.5l6.3 3.5-6.3 3.5z"/>
</svg>
{v.title}
</a>
{v.foods_mentioned.length > 0 && (

View File

@@ -34,7 +34,7 @@ export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
>
<option value="hybrid"></option>
<option value="keyword"></option>
<option value="semantic"></option>
<option value="semantic"></option>
</select>
<button
type="submit"