diff --git a/src/App.tsx b/src/App.tsx index 0805478..0c1339b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ const SettingsPanel = lazy(() => import("./components/SettingsPanel")); import { QueryClientProvider } from "@tanstack/react-query"; import { useBusLocations } from "./api/bus"; +import { type Language, LanguageProvider } from "./contexts/LanguageContext"; import { useBusSelection } from "./hooks/useBusSelection"; import { queryClient } from "./lib/query-client"; @@ -34,11 +35,12 @@ const ReactQueryDevtools: ComponentType = import.meta.env.DEV function App() { const mapId = useId(); const langId = useId(); - const [language, setLanguage] = useState(() => { + const [language, setLanguage] = useState(() => { try { - return typeof window !== "undefined" && window.localStorage - ? (localStorage.getItem("wtb:lang") ?? "ko") - : "ko"; + const stored = typeof window !== "undefined" && window.localStorage + ? localStorage.getItem("wtb:lang") + : null; + return stored === "en" ? "en" : "ko"; } catch { return "ko"; } @@ -63,19 +65,24 @@ function App() { return ( - - {import.meta.env.DEV && ( - - )} + > + + {import.meta.env.DEV && ( + + )} + ); } @@ -83,8 +90,8 @@ function App() { interface AppContentProps { mapId: string; langId: string; - language: string; - setLanguage: (lang: string) => void; + language: Language; + setLanguage: (lang: Language) => void; showSettings: boolean; toggleSettings: () => void; bubbleStop: { lat: number; lng: number; name: string } | undefined; diff --git a/src/api/bus.ts b/src/api/bus.ts index a5c2928..9154eb3 100644 --- a/src/api/bus.ts +++ b/src/api/bus.ts @@ -10,7 +10,7 @@ export const useBusLocations = () => { const data = await apiGet(API_ENDPOINTS.BUS.LOCATION); return Array.isArray(data) ? data : []; }, - refetchInterval: 20000, + refetchInterval: 5000, refetchIntervalInBackground: true, }); }; diff --git a/src/components/Bubble.tsx b/src/components/Bubble.tsx index a725639..ab68659 100644 --- a/src/components/Bubble.tsx +++ b/src/components/Bubble.tsx @@ -1,6 +1,7 @@ import { BusFront, X } from "lucide-react"; import { useEffect } from "react"; import { createRoot } from "react-dom/client"; +import { useTranslation } from "../contexts/LanguageContext"; const DISPLAY_NAME_MAP: Record = { 죽전역: "죽전역(단국대학교 방향)", @@ -16,6 +17,7 @@ type Props = { }; export default function Bubble({ stop, onClose }: Props) { + const { t, formatTime } = useTranslation(); useEffect(() => { if (typeof window.kakao === "undefined" || !window.map) return; @@ -48,14 +50,24 @@ export default function Bubble({ stop, onClose }: Props) { el.style.position = "fixed"; el.style.top = "16px"; el.style.left = "16px"; - el.style.right = "48px"; + el.style.right = "16px"; el.style.marginLeft = "auto"; el.style.marginRight = "auto"; - el.style.zIndex = "200"; + el.style.zIndex = "1001"; document.body.appendChild(el); const rawName = String(stop.name); - const displayName = DISPLAY_NAME_MAP[rawName] ?? rawName; + const translatedName = t(`busStop.${rawName}`); + + let displayName = translatedName; + if (DISPLAY_NAME_MAP[rawName]) { + const directionKey = DISPLAY_NAME_MAP[rawName].includes( + "죽전역" + ) + ? "direction.toJukjeon" + : "direction.toDKU"; + displayName = `${translatedName} (${t(directionKey)})`; + } const root = createRoot(el); root.render( @@ -64,8 +76,8 @@ export default function Bubble({ stop, onClose }: Props) { position: "fixed", top: "40px", left: "16px", - right: "48px", - zIndex: 200, + right: "16px", + zIndex: 10001, display: "flex", justifyContent: "center", }} @@ -136,7 +148,7 @@ export default function Bubble({ stop, onClose }: Props) { 24 - | 5분 남음 + | {formatTime(5)} @@ -164,7 +176,7 @@ export default function Bubble({ stop, onClose }: Props) { 720-3 - | 15분 남음 + | {formatTime(15)} @@ -225,7 +237,7 @@ export default function Bubble({ stop, onClose }: Props) { window.__currentBubbleOverlay = undefined; window.__currentBubbleStopName = undefined; }; - }, [stop, onClose]); + }, [stop, onClose, t, formatTime]); return null; } diff --git a/src/components/BusStops.tsx b/src/components/BusStops.tsx index fde2f7d..852c455 100644 --- a/src/components/BusStops.tsx +++ b/src/components/BusStops.tsx @@ -1,7 +1,8 @@ import { ChevronDown } from "lucide-react"; import { useEffect, useId, useRef, useState } from "react"; -import type { BusStop } from "../data/busStops"; import busIconSvg from "../assets/busIcon.svg"; +import { useTranslation } from "../contexts/LanguageContext"; +import type { BusStop } from "../data/busStops"; type Props = { busStops: BusStop[]; @@ -18,6 +19,7 @@ export default function BusStops({ onToggleBubble, busCount = 0, }: Props) { + const { t } = useTranslation(); const [openStops, setOpenStops] = useState(false); const [openNumbers, setOpenNumbers] = useState(false); const listId = useId(); @@ -101,7 +103,7 @@ export default function BusStops({ openStops ? "hidden" : "inline" }`} > - 버스 정류장 선택하기 + {t("busStops.selectStop")} - {stop.name} + {t(`busStop.${stop.name}`)} ))} @@ -157,9 +159,9 @@ export default function BusStops({ >
- 버스
@@ -168,7 +170,7 @@ export default function BusStops({ openNumbers ? "hidden" : "inline" }`} > - 버스 선택하기 + {t("busStops.selectBus")}
void; + language: Language; + setLanguage: (lang: Language) => void; onClose: () => void; } @@ -13,6 +14,7 @@ const SettingsPanel: React.FC = ({ setLanguage, onClose, }) => { + const { t } = useTranslation(); return (
= ({ marginBottom: 12, }} > -
설정
+
{t("settings.title")}
@@ -101,7 +103,7 @@ const SettingsPanel: React.FC = ({ style={{ display: "flex", flexDirection: "column", gap: 6 }} >
- 문의하기 + {t("settings.contact")}
= ({ rel="noopener noreferrer" style={{ color: "#0ea5e9", textDecoration: "none" }} > - 문의하기(구글폼) + {t("settings.contact")} @@ -121,7 +123,7 @@ const SettingsPanel: React.FC = ({ style={{ display: "flex", flexDirection: "column", gap: 6 }} >
- 사용 가이드 + {t("settings.userGuide")}
diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx new file mode 100644 index 0000000..f685d2f --- /dev/null +++ b/src/contexts/LanguageContext.tsx @@ -0,0 +1,112 @@ +import { createContext, type ReactNode, useContext } from "react"; + +export type Language = "ko" | "en"; + +interface Translations { + ko: Record; + en: Record; +} + +const translations: Translations = { + ko: { + // BusStops + "busStops.selectStop": "버스 정류장 선택하기", + "busStops.selectBus": "버스 선택하기", + + // Bus Stop Names + "busStop.평화의광장": "평화의광장", + "busStop.치과병원": "치과병원", + "busStop.정문": "정문", + "busStop.죽전역": "죽전역", + + // Directions + "direction.toDKU": "단국대학교 방향", + "direction.toJukjeon": "죽전역 방향", + + // SettingsPanel + "settings.title": "설정", + "settings.language": "언어", + "settings.korean": "한국어", + "settings.english": "English", + "settings.contact": "문의하기", + "settings.userGuide": "사용 가이드", + + // Common + "common.loading": "로딩 중...", + "common.error": "오류가 발생했습니다", + "common.noData": "데이터가 없습니다", + }, + en: { + // BusStops + "busStops.selectStop": "Select Bus Stop", + "busStops.selectBus": "Select Bus", + + // Bus Stop Names + "busStop.평화의광장": "Dankook Univ. Peace Square", + "busStop.치과병원": "Dankook Univ. Dental Hospital", + "busStop.정문": "Dankook Univ. Main Gate", + "busStop.죽전역": "Jukjeon Stn/ Shinsegae S. City", + + // Directions + "direction.toDKU": "→ Dankook Univ.", + "direction.toJukjeon": "→ Jukjeon Stn.", + + // SettingsPanel + "settings.title": "Settings", + "settings.language": "Language", + "settings.korean": "한국어", + "settings.english": "English", + "settings.contact": "Contact Us", + "settings.userGuide": "User Guide", + + // Common + "common.loading": "Loading...", + "common.error": "An error occurred", + "common.noData": "No data available", + }, +}; + +interface LanguageContextType { + language: Language; + setLanguage: (lang: Language) => void; + t: (key: string) => string; + formatTime: (minutes: number) => string; +} + +const LanguageContext = createContext( + undefined +); + +interface LanguageProviderProps { + children: ReactNode; + language: Language; + setLanguage: (lang: Language) => void; +} + +export function LanguageProvider({ + children, + language, + setLanguage, +}: LanguageProviderProps) { + const t = (key: string): string => { + return translations[language][key] || key; + }; + + const formatTime = (minutes: number): string => { + return language === "ko" ? `${minutes}분 남음` : `${minutes}min`; + }; + + return ( + + {children} + + ); +} + +export function useTranslation() { + const context = useContext(LanguageContext); + if (!context) { + throw new Error("useTranslation must be used within LanguageProvider"); + } + return context; +} diff --git a/src/utils/mapOverlays.ts b/src/utils/mapOverlays.ts index c582f31..85a8a48 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 busIconSvg from "../assets/busIcon.svg"; export interface OverlayHandle { setMap: (map: unknown) => void;