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);
+ },
+ };
});
};