diff --git a/src/App.tsx b/src/App.tsx index 0c1339b..61c4850 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,12 +5,14 @@ import { useCallback, useEffect, useId, + useRef, useState, } from "react"; import Bubble from "./components/Bubble"; import { BusStopsPanel } from "./components/BusStopsPanel"; import { MapContainer } from "./components/MapContainer"; import { SettingsButton } from "./components/SettingsButton"; +import { ToastProvider, useToast } from "./components/ui/use-toast"; const SettingsPanel = lazy(() => import("./components/SettingsPanel")); @@ -69,19 +71,21 @@ function App() { language={language} setLanguage={setLanguage} > - - {import.meta.env.DEV && ( - - )} + + + {import.meta.env.DEV && ( + + )} + ); @@ -112,8 +116,32 @@ function AppContent({ bubbleStop, setBubbleStop, }: AppContentProps) { - const { data: buses = [] } = useBusLocations(); + const { toast } = useToast(); + const errorShownRef = useRef(false); + + const handleBusLocationError = useCallback((message: string) => { + // Only show toast once to avoid spamming on refetch failures + if (!errorShownRef.current) { + toast({ + title: "버스 위치 조회 오류", + description: message, + variant: "destructive", + }); + errorShownRef.current = true; + // Reset after 30 seconds to allow showing error again if it persists + setTimeout(() => { + errorShownRef.current = false; + }, 30000); + } + }, [toast]); + + const { data: buses = [] } = useBusLocations(handleBusLocationError); const handleBusNumberSelect = useBusSelection(buses, setBubbleStop); + const handleStopClick = useCallback( + (stop: { lat: number; lng: number; name: string }) => + setBubbleStop(stop), + [setBubbleStop] + ); return (
) : null} - + setBubbleStop(undefined)} diff --git a/src/api/bus.ts b/src/api/bus.ts index 9154eb3..87c1ccb 100644 --- a/src/api/bus.ts +++ b/src/api/bus.ts @@ -2,15 +2,27 @@ import { useQuery } from "@tanstack/react-query"; import type { Bus } from "../data/bus"; import { apiGet } from "../lib/api"; import { API_ENDPOINTS } from "../lib/endpoints"; +import { handleApiError } from "../lib/error"; -export const useBusLocations = () => { +export const useBusLocations = ( + onError?: (message: string) => void +) => { return useQuery({ queryKey: ["busLocations"], queryFn: async () => { - const data = await apiGet(API_ENDPOINTS.BUS.LOCATION); - return Array.isArray(data) ? data : []; + try { + const data = await apiGet(API_ENDPOINTS.BUS.LOCATION); + return Array.isArray(data) ? data : []; + } catch (error) { + const errorMessage = await handleApiError(error); + if (onError) { + onError(errorMessage); + } + return []; + } }, refetchInterval: 5000, refetchIntervalInBackground: true, + retry: 2, }); }; diff --git a/src/components/MapContainer.tsx b/src/components/MapContainer.tsx index dd98d42..289626f 100644 --- a/src/components/MapContainer.tsx +++ b/src/components/MapContainer.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { useBusLocations } from "../api/bus"; +import type { BusStop } from "../data/busStops"; import { busStops } from "../data/busStops"; import { useKakaoMap } from "../hooks/useKakaoMap"; import { useMapEventHandlers } from "../hooks/useMapEventHandlers"; @@ -10,18 +11,20 @@ interface MapContainerProps { mapId: string; children?: ReactNode; selectedStopName?: string; + onStopClick?: (stop: BusStop) => void; } export const MapContainer = ({ mapId, children, selectedStopName, + onStopClick, }: MapContainerProps) => { const { toast } = useToast(); const map = useKakaoMap({ mapId, toast }); const { data: buses = [] } = useBusLocations(); - useMapOverlays(map, [...busStops], buses, selectedStopName); + useMapOverlays(map, [...busStops], buses, selectedStopName, onStopClick); useMapEventHandlers(mapId); return ( diff --git a/src/hooks/useKakaoMap.ts b/src/hooks/useKakaoMap.ts index bac59bf..638eb10 100644 --- a/src/hooks/useKakaoMap.ts +++ b/src/hooks/useKakaoMap.ts @@ -49,66 +49,6 @@ export const useKakaoMap = ({ mapId, toast }: UseKakaoMapOptions) => { window.map = map; setMapInstance(map); - - // WebView hooks - if (typeof window.__moveFromRN !== "function") { - window.__moveFromRN = (lat: number, lng: number) => { - try { - if ( - window.map && - typeof window.map.setCenter === "function" && - typeof window.kakao !== "undefined" - ) { - window.map.setCenter( - new window.kakao.maps.LatLng(lat, lng) - ); - } else { - window.__pendingMove = { lat, lng }; - } - } catch { - // ignore - } - }; - } - - if ( - window.__pendingMove && - Number.isFinite(window.__pendingMove.lat) && - Number.isFinite(window.__pendingMove.lng) - ) { - try { - window.map?.setCenter( - new window.kakao.maps.LatLng( - window.__pendingMove.lat, - window.__pendingMove.lng - ) - ); - window.__pendingMove = null; - } catch { - // ignore - } - } - - if (typeof window.__onMapReady === "function") { - try { - window.__onMapReady(); - } catch { - // ignore - } - } - - if ( - window.ReactNativeWebView && - typeof window.ReactNativeWebView.postMessage === "function" - ) { - try { - window.ReactNativeWebView.postMessage( - JSON.stringify({ type: "MAP_READY" }) - ); - } catch { - // ignore - } - } }; const loadKakaoMapScript = () => { diff --git a/src/hooks/useMapEventHandlers.ts b/src/hooks/useMapEventHandlers.ts index 3012aec..8e68df2 100644 --- a/src/hooks/useMapEventHandlers.ts +++ b/src/hooks/useMapEventHandlers.ts @@ -2,52 +2,19 @@ import { useEffect } from "react"; export const useMapEventHandlers = (mapId: string) => { useEffect(() => { - const isRN = !!window.ReactNativeWebView; - const selfOrigin = location.protocol.startsWith("http") - ? location.origin - : undefined; - const allowedOrigins = new Set([ - ...(selfOrigin ? [selfOrigin] : []), - "http://localhost:3000", - "http://localhost:5173", - ]); - - const messageHandler = (event: MessageEvent) => { - // RN(WebView)에서만 origin === "null" 허용 - if ( - !(isRN && event.origin === "null") && - !allowedOrigins.has(event.origin) - ) - return; - }; - const containerEl = document.getElementById(mapId); const gestureHandler = (e: Event) => { const t = e.target as HTMLElement | null; if (containerEl && t && containerEl.contains(t) && e.cancelable) e.preventDefault(); }; - const touchMoveHandler = (e: TouchEvent) => { - if ( - containerEl && - e.target && - containerEl.contains(e.target as Node) - ) - e.preventDefault(); - }; - window.addEventListener("message", messageHandler); document.addEventListener("gesturestart", gestureHandler, { passive: false, }); - containerEl?.addEventListener("touchmove", touchMoveHandler, { - passive: false, - }); return () => { - window.removeEventListener("message", messageHandler); document.removeEventListener("gesturestart", gestureHandler); - containerEl?.removeEventListener("touchmove", touchMoveHandler); }; }, [mapId]); }; diff --git a/src/hooks/useMapOverlays.ts b/src/hooks/useMapOverlays.ts index 5304d58..01b7b40 100644 --- a/src/hooks/useMapOverlays.ts +++ b/src/hooks/useMapOverlays.ts @@ -11,13 +11,14 @@ export const useMapOverlays = ( map: unknown, busStops: BusStop[], buses: Bus[], - selectedStopName?: string + selectedStopName?: string, + onStopClick?: (stop: BusStop) => void ) => { useEffect(() => { if (!map) return; const overlays: OverlayHandle[] = [ - ...createBusStopOverlays(map, busStops, selectedStopName), + ...createBusStopOverlays(map, busStops, selectedStopName, onStopClick), ...createBusOverlays(map, buses), ]; @@ -28,7 +29,12 @@ export const useMapOverlays = ( } catch { // ignore cleanup errors } + try { + overlay.cleanup?.(); + } catch { + // ignore cleanup errors + } }); }; - }, [map, busStops, buses, selectedStopName]); + }, [map, busStops, buses, selectedStopName, onStopClick]); }; diff --git a/src/types/kakao.d.ts b/src/types/kakao.d.ts index 8b43214..236c77c 100644 --- a/src/types/kakao.d.ts +++ b/src/types/kakao.d.ts @@ -52,12 +52,6 @@ declare global { }; map?: KakaoMap; __panAnimationId?: number; - // WebView / embed hooks used by React Native WebView or other hosts - __moveFromRN?: (lat: number, lng: number) => void; - __onMapReady?: () => void; - __pendingMove?: { lat: number; lng: number } | null; - // Minimal React Native WebView typing to allow postMessage from the web app - ReactNativeWebView?: { postMessage: (message: string) => void }; // internal bubble overlay handle / name used by the app __currentBubbleOverlay?: KakaoOverlay | undefined; __currentBubbleStopName?: string | undefined; diff --git a/src/utils/mapOverlays.ts b/src/utils/mapOverlays.ts index 85a8a48..e2b9c76 100644 --- a/src/utils/mapOverlays.ts +++ b/src/utils/mapOverlays.ts @@ -4,6 +4,7 @@ import type { BusStop } from "../data/busStops"; export interface OverlayHandle { setMap: (map: unknown) => void; + cleanup?: () => void; } // Helper to create Lucide icon as SVG element @@ -130,7 +131,8 @@ const createIconSVG = (iconType: "mapPin" | "bus", showCircle = false) => { export const createBusStopOverlays = ( map: unknown, busStops: BusStop[], - selectedStopName?: string + selectedStopName?: string, + onStopClick?: (stop: BusStop) => void ): OverlayHandle[] => { if (!map || typeof window === "undefined" || !window.kakao?.maps) return []; @@ -143,12 +145,40 @@ export const createBusStopOverlays = ( busIconDiv.style.display = "flex"; busIconDiv.style.alignItems = "center"; busIconDiv.style.justifyContent = "center"; - busIconDiv.setAttribute("role", "img"); + busIconDiv.style.cursor = "pointer"; + busIconDiv.setAttribute("role", "button"); busIconDiv.setAttribute("aria-label", `정류장: ${stop.name}`); + busIconDiv.setAttribute("tabindex", "0"); const iconSVG = createIconSVG("mapPin", isSelected); busIconDiv.appendChild(iconSVG); + // Named click handler for proper cleanup + const handleClick = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (onStopClick) { + onStopClick(stop); + } + }; + + // Named keydown handler for proper cleanup + const handleKeydown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + e.preventDefault(); + if (onStopClick) { + onStopClick(stop); + } + } + }; + + // 클릭 이벤트 추가 + busIconDiv.addEventListener("click", handleClick); + + // 키보드 접근성 + busIconDiv.addEventListener("keydown", handleKeydown); + const markerPosition = new window.kakao.maps.LatLng(stop.lat, stop.lng); const overlay = new window.kakao.maps.CustomOverlay({ position: markerPosition, @@ -157,7 +187,16 @@ export const createBusStopOverlays = ( }); (overlay as unknown as { setMap: (m: unknown) => void }).setMap(map); - return overlay as OverlayHandle; + // Return overlay with cleanup method + return { + setMap: (m: unknown) => { + (overlay as unknown as { setMap: (m: unknown) => void }).setMap(m); + }, + cleanup: () => { + busIconDiv.removeEventListener("click", handleClick); + busIconDiv.removeEventListener("keydown", handleKeydown); + }, + }; }); };