diff --git a/src/api/bus.ts b/src/api/bus.ts index 9b198d5..b0f40e3 100644 --- a/src/api/bus.ts +++ b/src/api/bus.ts @@ -1,15 +1,17 @@ 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"; +import type { ArrivalsResponse, Shuttle } from "../types/bus"; export const useBusLocations = (onError?: (message: string) => void) => { return useQuery({ queryKey: ["busLocations"], queryFn: async () => { try { - const data = await apiGet(API_ENDPOINTS.BUS.LOCATION); + const data = await apiGet( + API_ENDPOINTS.SHUTTLE.LOCATIONS + ); return Array.isArray(data) ? data : []; } catch (error) { const errorMessage = await handleApiError(error); @@ -24,3 +26,26 @@ export const useBusLocations = (onError?: (message: string) => void) => { retry: 2, }); }; + +// Types for arrivals API + +export const useBusArrivals = (onError?: (message: string) => void) => { + return useQuery({ + queryKey: ["busArrivals"], + queryFn: async (): Promise => { + try { + const data = await apiGet( + API_ENDPOINTS.BUS.ARRIVALS + ); + return data || null; + } catch (error) { + const errorMessage = await handleApiError(error); + if (onError) onError(errorMessage); + return null; + } + }, + refetchInterval: 30000, + refetchIntervalInBackground: true, + retry: 2, + }); +}; diff --git a/src/components/Bubble.tsx b/src/components/Bubble.tsx index 4d10bac..9922160 100644 --- a/src/components/Bubble.tsx +++ b/src/components/Bubble.tsx @@ -1,12 +1,14 @@ import { BusFront, X } from "lucide-react"; import { useEffect } from "react"; import { createRoot } from "react-dom/client"; +import { useBusArrivals } from "../api/bus"; import { useTranslation } from "../contexts/LanguageContext"; const DISPLAY_NAME_MAP: Record = { 죽전역: "죽전역(단국대학교 방향)", 치과병원: "치과병원(단국대학교 방향)", 정문: "정문(죽전역 방향)", + 인문관: "인문관(죽전역 방향)", }; type Stop = { lat: number; lng: number; name: string }; @@ -18,6 +20,7 @@ type Props = { export default function Bubble({ stop, onClose }: Props) { const { t, formatTime } = useTranslation(); + const { data: arrivals } = useBusArrivals(); useEffect(() => { if (typeof window.kakao === "undefined" || !window.map) return; @@ -85,9 +88,11 @@ export default function Bubble({ stop, onClose }: Props) { } } - // Pre-calculate time labels - const time1Label = formatTime(5); - const time2Label = formatTime(15); + // Find arrival info for this stop (match by substring) + const arrivalStop = + arrivals?.stops?.find((s) => + String(s.stopName).includes(rawName) + ) || null; const root = createRoot(el); root.render( @@ -144,62 +149,87 @@ export default function Bubble({ stop, onClose }: Props) { {!rawName.startsWith("bus.") && ( <> -
- - - - 24 - - - | {time1Label} - - -
-
- - - - 720-3 - - - | {time2Label} - - -
+ {(arrivalStop?.buses ?? []).length === + 0 && ( +
+ {t("common.noData")} +
+ )} + + {(arrivalStop?.buses ?? []).map( + (b, idx) => { + const route = String( + b.routeName || "" + ); + const color = + route === "24" + ? "#f6c341" + : route === "720-3" + ? "#7dd3fc" + : "#000000"; + let timeLabel: string; + if (b.minutesLeft === null) { + timeLabel = + t("common.noArrival"); + } else if ( + b.minutesLeft === 1 + ) { + timeLabel = t( + "common.arrivingSoon" + ); + } else { + timeLabel = formatTime( + b.minutesLeft + ); + } + + return ( +
+ + + + {b.routeName} + + + | {timeLabel} + + +
+ ); + } + )} )} @@ -257,7 +287,7 @@ export default function Bubble({ stop, onClose }: Props) { window.__currentBubbleOverlay = undefined; window.__currentBubbleStopName = undefined; }; - }, [stop, onClose, t, formatTime]); + }, [stop, onClose, t, formatTime, arrivals]); return null; } diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx index 3ca61d8..ebced5c 100644 --- a/src/contexts/LanguageContext.tsx +++ b/src/contexts/LanguageContext.tsx @@ -17,6 +17,7 @@ const translations: Translations = { "busStop.평화의광장": "평화의광장", "busStop.치과병원": "치과병원", "busStop.정문": "정문", + "busStop.인문관": "인문관", "busStop.죽전역": "죽전역", // Directions @@ -40,6 +41,8 @@ const translations: Translations = { "common.loading": "로딩 중...", "common.error": "오류가 발생했습니다", "common.noData": "데이터가 없습니다", + "common.noArrival": "도착 정보 없음", + "common.arrivingSoon": "곧 도착", }, en: { // BusStops @@ -50,6 +53,7 @@ const translations: Translations = { "busStop.평화의광장": "Dankook Univ. Peace Square", "busStop.치과병원": "Dankook Univ. Dental Hospital", "busStop.정문": "Dankook Univ. Main Gate", + "busStop.인문관": "Humanities Building", "busStop.죽전역": "Jukjeon Stn/ Shinsegae S. City", // Directions @@ -73,6 +77,8 @@ const translations: Translations = { "common.loading": "Loading...", "common.error": "An error occurred", "common.noData": "No data available", + "common.noArrival": "No arrival info", + "common.arrivingSoon": "Arriving soon", }, }; diff --git a/src/data/bus.ts b/src/data/bus.ts deleted file mode 100644 index 37e3cc3..0000000 --- a/src/data/bus.ts +++ /dev/null @@ -1,39 +0,0 @@ -export interface Bus { - shuttleId: string; - lat: number; - lng: number; - direction: boolean | null; -} - -export const buses: ReadonlyArray = [ - { - shuttleId: "bus1", - lat: 37.323494, - lng: 127.123008, - direction: null, - }, - { - shuttleId: "bus2", - lat: 37.323637, - lng: 127.120047, - direction: true, // 단국대학교 - }, - { - shuttleId: "bus3", - lat: 37.323779, - lng: 127.117087, - direction: false, // 죽전역 - }, - { - shuttleId: "bus4", - lat: 37.323921, - lng: 127.114126, - direction: true, // 단국대학교 - }, - { - shuttleId: "bus5", - lat: 37.324063, - lng: 127.111166, - direction: false, // 죽전역 - }, -]; diff --git a/src/data/busStops.ts b/src/data/busStops.ts index 2c31d70..2c46570 100644 --- a/src/data/busStops.ts +++ b/src/data/busStops.ts @@ -8,5 +8,6 @@ export const busStops: BusStop[] = [ { name: "평화의광장", lat: 37.320146, lng: 127.12884 }, { name: "치과병원", lat: 37.322292, lng: 127.125436 }, { name: "정문", lat: 37.323352, lng: 127.125968 }, + { name: "인문관", lat: 37.322209, lng: 127.128268 }, { name: "죽전역", lat: 37.324206, lng: 127.108205 }, ]; diff --git a/src/hooks/useBusSelection.ts b/src/hooks/useBusSelection.ts index e97a9af..013efa1 100644 --- a/src/hooks/useBusSelection.ts +++ b/src/hooks/useBusSelection.ts @@ -1,9 +1,9 @@ import type { Dispatch, SetStateAction } from "react"; -import type { Bus } from "../data/bus"; +import type { Shuttle } from "../types/bus"; import { moveToLocation } from "./useMapMovement"; export const useBusSelection = ( - buses: Bus[], + buses: Shuttle[], setBubbleStop: Dispatch< SetStateAction<{ lat: number; lng: number; name: string } | undefined> > diff --git a/src/hooks/useMapOverlays.ts b/src/hooks/useMapOverlays.ts index 212de9c..10da403 100644 --- a/src/hooks/useMapOverlays.ts +++ b/src/hooks/useMapOverlays.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; -import type { Bus } from "../data/bus"; import type { BusStop } from "../data/busStops"; +import type { Shuttle } from "../types/bus"; import type { KakaoMap } from "../types/kakao"; import { clearAllBusOverlays, @@ -11,7 +11,7 @@ import { export const useMapOverlays = ( map: KakaoMap | null, busStops: BusStop[], - buses: Bus[], + buses: Shuttle[], selectedStopName?: string, onStopClick?: (stop: BusStop) => void ) => { diff --git a/src/lib/endpoints.ts b/src/lib/endpoints.ts index a6b9ad2..617a5c7 100644 --- a/src/lib/endpoints.ts +++ b/src/lib/endpoints.ts @@ -1,6 +1,9 @@ export const API_ENDPOINTS = { - // 시내 버스 관련 + // 셔틀 버스 관련 + SHUTTLE: { + LOCATIONS: "api/shuttle/locations", + }, BUS: { - LOCATION: "api/shuttle/locations", + ARRIVALS: "api/bus/arrivals", }, } as const; diff --git a/src/types/bus.ts b/src/types/bus.ts new file mode 100644 index 0000000..e11249e --- /dev/null +++ b/src/types/bus.ts @@ -0,0 +1,23 @@ +export interface Shuttle { + shuttleId: string; + lat: number; + lng: number; + direction: boolean | null; +} + +export interface ArrivalBus { + routeName: string; + minutesLeft: number | null; + remainingSeats: number | null; +} + +export interface ArrivalStop { + stopCode: string; + stopName: string; + buses: ArrivalBus[]; +} + +export interface ArrivalsResponse { + updatedAt: string; + stops: ArrivalStop[]; +} diff --git a/src/utils/mapOverlays.ts b/src/utils/mapOverlays.ts index bb85b45..bc96572 100644 --- a/src/utils/mapOverlays.ts +++ b/src/utils/mapOverlays.ts @@ -1,6 +1,6 @@ import busIconSvg from "../assets/busIcon.svg"; -import type { Bus } from "../data/bus"; import type { BusStop } from "../data/busStops"; +import type { Shuttle } from "../types/bus"; import type { KakaoMap, KakaoOverlay } from "../types/kakao"; // 상수 정의 @@ -284,7 +284,7 @@ export const createBusStopOverlays = ( export const createBusOverlays = ( map: KakaoMap, - buses: Bus[] + buses: Shuttle[] ): OverlayHandle[] => { if (!map || typeof window === "undefined" || !window.kakao?.maps) return [];