diff --git a/ssh/package-lock.json b/ssh/package-lock.json index b0b0f84..5e3a449 100644 --- a/ssh/package-lock.json +++ b/ssh/package-lock.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@eslint/js": "^9.33.0", + "@types/node": "^24.3.1", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.7", "@types/styled-components": "^5.1.34", @@ -2443,6 +2444,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, "node_modules/@types/react": { "version": "19.1.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", @@ -6038,6 +6049,13 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", diff --git a/ssh/package.json b/ssh/package.json index 18b0a74..4e5c9b1 100644 --- a/ssh/package.json +++ b/ssh/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@eslint/js": "^9.33.0", + "@types/node": "^24.3.1", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.7", "@types/styled-components": "^5.1.34", diff --git a/ssh/src/pages/AtmMap/index.tsx b/ssh/src/pages/AtmMap/index.tsx index c0dba83..a01db58 100644 --- a/ssh/src/pages/AtmMap/index.tsx +++ b/ssh/src/pages/AtmMap/index.tsx @@ -8,41 +8,23 @@ const GAP = '1.25rem'; const FOOTER = 'var(--app-footer-h, 0px)'; const BOTTOM_OFFSET = `calc(${FOOTER} + env(safe-area-inset-bottom, 0px))`; +const SEARCH_HERE_THRESHOLD_KM = 0.05; + declare global { interface Window { kakao: any; } } - -const ATMS = [ - { - id: 'atm-1', - brand: 'KB국민은행', - name: '아미고_삼성중앙역점', - address: '서울 강남구 봉은사로 471', - lat: 37.51064, - lng: 127.0583, - fee: 0, - }, - { - id: 'atm-2', - brand: 'KB국민은행', - name: '대명텔레콤_코엑스몰점', - address: '서울 강남구 영동대로 513', - lat: 37.5113, - lng: 127.0589, - fee: 1100, - }, - { - id: 'atm-3', - brand: 'KB국민은행', - name: '예당 논현점', - address: '서울 강남구 학동로 331', - lat: 37.5139, - lng: 127.0335, - fee: 0, - }, -]; +type Atm = { + id: string; + brand: string; + name: string; + address: string; + lat: number; + lng: number; + type: 'ATM' | 'BRANCH' | 'OTHER'; +}; +type Bounds = { swLat: number; swLng: number; neLat: number; neLng: number }; function loadKakaoScript(appKey: string, libraries: string[] = ['services']) { return new Promise((resolve, reject) => { @@ -65,11 +47,6 @@ function loadKakaoScript(appKey: string, libraries: string[] = ['services']) { } const BRANDS = ['전체', 'KB국민은행']; -const FEE_CHIPS: { label: string; value: 'all' | 'free' | 'paid' }[] = [ - { label: '전체', value: 'all' }, - { label: '무료', value: 'free' }, - { label: '유료', value: 'paid' }, -]; function getDistanceKm(lat1: number, lon1: number, lat2: number, lon2: number) { const R = 6371; @@ -82,6 +59,22 @@ function getDistanceKm(lat1: number, lon1: number, lat2: number, lon2: number) { return R * c; } +function buildUserMarkerDataUrl() { + const svg = ` + + + + + + + + + + + `; + return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg); +} + const Container = styled.div` position: relative; height: 100vh; @@ -228,19 +221,37 @@ const ListItem = styled.li<{ $selected: boolean }>` outline-offset: 2px; } `; -const ItemTitle = styled.div` +const ItemTitleRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; font-weight: 700; font-size: 15px; margin-bottom: 4px; `; +const ItemName = styled.span` + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const ItemTypePill = styled.span` + margin-left: 6px; + padding: 2px 6px; + border-radius: 9999px; + font-size: 11px; + font-weight: 700; + background: ${ACCENT}; + color: #fff; +`; const ItemAddress = styled.div` font-size: 12px; color: rgba(17, 24, 39, 0.7); margin-bottom: 6px; `; -const ItemFee = styled.div` - font-size: 12px; -`; const Fab = styled.button` position: fixed; right: ${GAP}; @@ -259,6 +270,22 @@ const Fab = styled.button` font-size: 22px; line-height: 1; `; + +const SearchHereBtn = styled.button` + position: fixed; + left: 50%; + transform: translateX(-50%); + top: calc(${TOP_OFFSET} + 8px); + z-index: 30; + padding: 0.5rem 0.875rem; + border-radius: 9999px; + border: 1px solid #e5e7eb; + background: #ffffff; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); + font-weight: 700; + font-size: 13px; + cursor: pointer; +`; const ErrSdk = styled.div` position: fixed; left: ${GAP}; @@ -302,12 +329,33 @@ const AtmMapPage = () => { const mapRef = useRef(null); const kakaoMapRef = useRef(null); const markersRef = useRef([]); - const userCircleRef = useRef(null); + const userMarkerRef = useRef(null); const [brand, setBrand] = useState('전체'); - const [feeFilter, setFeeFilter] = useState<'all' | 'free' | 'paid'>('all'); const [q, setQ] = useState(''); + + const [placeFilter, setPlaceFilter] = useState<'all' | 'branch' | 'atm'>('all'); const [selectedId, setSelectedId] = useState(null); + const [items, setItems] = useState([]); + + const [center, setCenter] = useState<{ lat: number; lng: number }>({ + lat: 37.51064, + lng: 127.0583, + }); + + const [viewCenter, setViewCenter] = useState<{ lat: number; lng: number }>({ + lat: 37.51064, + lng: 127.0583, + }); + const [bounds, setBounds] = useState(null); + const [ready, setReady] = useState(false); + + const [dirty, setDirty] = useState(false); + + const abortRef = useRef(null); + + const idleTimerRef = useRef(undefined); + const safeItems = useMemo(() => (Array.isArray(items) ? items : []), [items]); const [userLat, setUserLat] = useState(null); const [userLng, setUserLng] = useState(null); @@ -318,24 +366,106 @@ const AtmMapPage = () => { const getKakaoKey = () => (import.meta as any).env?.VITE_KAKAO_MAP_KEY as string | undefined; + const isAtmPlace = (a: Atm) => { + if (a.type === 'ATM') return true; + if (a.type === 'BRANCH') return false; + const hay = `${a.name} ${a.address}`.toLowerCase(); + return /atm|무인|자동화|현금|cd|지급기/.test(hay); + }; + const isBranchPlace = (a: Atm) => { + if (a.type === 'BRANCH') return true; + if (a.type === 'ATM') return false; + const hay = `${a.name} ${a.address}`.toLowerCase(); + return /(지점|영업점|은행)/.test(hay) && !/atm|무인|자동화|현금|cd|지급기/.test(hay); + }; const filteredWithDistance = useMemo(() => { - const base = ATMS.filter((a) => { + const base = safeItems.filter((a) => { const matchBrand = brand === '전체' ? true : a.brand === brand; - const matchFee = feeFilter === 'all' ? true : feeFilter === 'free' ? a.fee === 0 : a.fee > 0; const matchQ = q ? `${a.name} ${a.address}`.toLowerCase().includes(q.toLowerCase()) : true; - return matchBrand && matchFee && matchQ; + const matchPlace = + placeFilter === 'all' ? true : placeFilter === 'atm' ? isAtmPlace(a) : isBranchPlace(a); + return matchBrand && matchQ && matchPlace; }); if (!hasUserLocation) return base.map((a) => ({ ...a, distanceKm: null as number | null })); return base .map((a) => ({ ...a, distanceKm: getDistanceKm(userLat!, userLng!, a.lat, a.lng) })) .sort((x, y) => x.distanceKm! - y.distanceKm!); - }, [brand, feeFilter, q, userLat, userLng, hasUserLocation]); + }, [brand, placeFilter, q, userLat, userLng, hasUserLocation, safeItems]); const selected = useMemo(() => { const found = filteredWithDistance.find((a) => a.id === selectedId); return found || filteredWithDistance[0] || { lat: 37.51064, lng: 127.0583 }; }, [filteredWithDistance, selectedId]); + useEffect(() => { + if (!selectedId) return; + if (typeof selected.lat === 'number' && typeof selected.lng === 'number') { + setCenter({ lat: selected.lat, lng: selected.lng }); + setDirty(false); + setViewCenter({ lat: selected.lat, lng: selected.lng }); + } + }, [selectedId, selected.lat, selected.lng]); + + useEffect(() => { + if (!ready) return; + const params = new URLSearchParams({ + lat: String(center.lat), + lng: String(center.lng), + brand, + q, + }); + if (bounds) { + params.set('swLat', String(bounds.swLat)); + params.set('swLng', String(bounds.swLng)); + params.set('neLat', String(bounds.neLat)); + params.set('neLng', String(bounds.neLng)); + } else { + params.set('radius', '1500'); + } + const url = `/api/atms/nearby?${params.toString()}`; + + if (abortRef.current) { + abortRef.current.abort(); + } + const ac = new AbortController(); + abortRef.current = ac; + + fetch(url, { signal: ac.signal, headers: { Accept: 'application/json' } }) + .then(async (r) => { + const ct = r.headers.get('content-type') || ''; + if (!r.ok) { + const text = await r.text().catch(() => ''); + throw new Error(`HTTP ${r.status} ${r.statusText} | ${text.slice(0, 160)}`); + } + if (!ct.includes('application/json')) { + const text = await r.text().catch(() => ''); + throw new Error(`Unexpected content-type: ${ct}. First 160 chars: ${text.slice(0, 160)}`); + } + return r.json(); + }) + .then((data) => { + if (Array.isArray(data)) { + setItems(data as Atm[]); + } else { + console.error('ATM API returned non-array payload:', data); + setItems([]); + } + }) + .catch((e: any) => { + if (e?.name === 'AbortError') return; + console.error('ATM fetch failed:', e); + }) + .finally(() => { + if (abortRef.current === ac) { + abortRef.current = null; + } + }); + + return () => { + ac.abort(); + }; + }, [ready, center.lat, center.lng, brand, q, bounds]); + useEffect(() => { let cancelled = false; const init = async () => { @@ -351,8 +481,11 @@ const AtmMapPage = () => { if (cancelled || !mapRef.current) return; const center = new window.kakao.maps.LatLng(selected.lat, selected.lng); kakaoMapRef.current = new window.kakao.maps.Map(mapRef.current, { center, level: 5 }); + const b0 = captureBounds(); + if (b0) setBounds(b0); + setReady(true); renderMarkers(); - renderUserCircle(); + renderUserMarker(); requestAnimationFrame(() => safeRelayout(kakaoMapRef.current, selected.lat, selected.lng), ); @@ -367,12 +500,36 @@ const AtmMapPage = () => { }; }, []); + useEffect(() => { + if (!window.kakao?.maps || !kakaoMapRef.current) return; + + const onIdle = () => { + if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current); + idleTimerRef.current = window.setTimeout(() => { + const c = kakaoMapRef.current!.getCenter(); + const next = { lat: c.getLat(), lng: c.getLng() }; + setViewCenter(next); + const movedKm = getDistanceKm(center.lat, center.lng, next.lat, next.lng); + setDirty(movedKm >= SEARCH_HERE_THRESHOLD_KM); + }, 250); + }; + + window.kakao.maps.event.addListener(kakaoMapRef.current, 'idle', onIdle); + return () => { + window.kakao.maps.event.removeListener(kakaoMapRef.current!, 'idle', onIdle); + }; + }, [center.lat, center.lng, kakaoMapRef.current]); + + useEffect(() => { + setDirty(false); + }, [center.lat, center.lng]); + useEffect(() => { if (!mapRef.current) return; const el = mapRef.current; const onResize = () => { if (!kakaoMapRef.current) return; - safeRelayout(kakaoMapRef.current, selected.lat, selected.lng); + safeRelayout(kakaoMapRef.current); }; const ro = new ResizeObserver(onResize); ro.observe(el); @@ -386,9 +543,11 @@ const AtmMapPage = () => { useEffect(() => { if (!window.kakao?.maps || !kakaoMapRef.current) return; renderMarkers(); - renderUserCircle(); - kakaoMapRef.current.panTo(new window.kakao.maps.LatLng(selected.lat, selected.lng)); - safeRelayout(kakaoMapRef.current, selected.lat, selected.lng); + renderUserMarker(); + if (selectedId) { + kakaoMapRef.current.panTo(new window.kakao.maps.LatLng(selected.lat, selected.lng)); + } + safeRelayout(kakaoMapRef.current); }, [filteredWithDistance, selected, hasUserLocation, userLat, userLng]); function renderMarkers() { @@ -403,25 +562,49 @@ const AtmMapPage = () => { }); } - function renderUserCircle() { + function renderUserMarker() { if (!window.kakao?.maps || !kakaoMapRef.current) return; - if (userCircleRef.current) userCircleRef.current.setMap(null); - if (hasUserLocation) { - const centerUser = new window.kakao.maps.LatLng(userLat!, userLng!); - userCircleRef.current = new window.kakao.maps.Circle({ - center: centerUser, - radius: 120, - strokeWeight: 3, - strokeColor: ACCENT, - strokeOpacity: 0.9, - strokeStyle: 'shortdash', - fillColor: ACCENT, - fillOpacity: 0.15, - }); - userCircleRef.current.setMap(kakaoMapRef.current); + + if (!hasUserLocation) { + if (userMarkerRef.current) { + userMarkerRef.current.setMap(null); + userMarkerRef.current = null; + } + return; } - } + const pos = new window.kakao.maps.LatLng(userLat!, userLng!); + if (userMarkerRef.current) { + userMarkerRef.current.setPosition(pos); + userMarkerRef.current.setMap(kakaoMapRef.current); + return; + } + + const imgSrc = buildUserMarkerDataUrl(); + const size = new window.kakao.maps.Size(36, 36); + const offset = new window.kakao.maps.Point(18, 32); + const markerImage = new window.kakao.maps.MarkerImage(imgSrc, size, { offset }); + + const marker = new window.kakao.maps.Marker({ + position: pos, + image: markerImage, + zIndex: 999, + clickable: false, + }); + marker.setMap(kakaoMapRef.current); + userMarkerRef.current = marker; + } + function captureBounds(): Bounds | null { + if (!window.kakao?.maps || !kakaoMapRef.current) return null; + try { + const b = kakaoMapRef.current.getBounds(); + const sw = b.getSouthWest(); + const ne = b.getNorthEast(); + return { swLat: sw.getLat(), swLng: sw.getLng(), neLat: ne.getLat(), neLng: ne.getLng() }; + } catch { + return null; + } + } const requestUserLocation = () => { setGeoError(null); if (!navigator.geolocation) { @@ -449,9 +632,20 @@ const AtmMapPage = () => { kakaoMapRef.current.panTo(new window.kakao.maps.LatLng(userLat!, userLng!)); }; + const handleSearchHere = () => { + const b = captureBounds(); + if (b) setBounds(b); + setCenter({ lat: viewCenter.lat, lng: viewCenter.lng }); + }; + return ( + {dirty && ( + + 이 지역 검색하기 + + )} { - - {FEE_CHIPS.map((c) => ( + + {[ + { label: '전체', value: 'all' }, + { label: '지점', value: 'branch' }, + { label: 'ATM', value: 'atm' }, + ].map((c) => ( setFeeFilter(c.value)} + $active={placeFilter === c.value} + onClick={() => setPlaceFilter(c.value as 'all' | 'branch' | 'atm')} > {c.label} @@ -499,11 +697,13 @@ const AtmMapPage = () => { role="button" tabIndex={0} > - {a.name} + + {a.name} + + {a.type === 'ATM' ? 'ATM' : a.type === 'BRANCH' ? '지점' : '기타'} + + {a.address} - - {a.fee === 0 ? '수수료 무료' : `수수료 ${a.fee.toLocaleString()}원`} - ))} diff --git a/ssh/vite.config.ts b/ssh/vite.config.ts index f29a1c7..8c23bf3 100644 --- a/ssh/vite.config.ts +++ b/ssh/vite.config.ts @@ -1,12 +1,23 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; +/// +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; // https://vite.dev/config/ export default defineConfig({ plugins: [react()], resolve: { alias: { - "@": "/src", + '@': '/src', + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, }, }, });