From ad5a86b5e4e83522e56b0c19ef9fe140eb6d7d63 Mon Sep 17 00:00:00 2001 From: HOKAGO-MEMORIES Date: Fri, 19 Jun 2026 00:30:34 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(fe):=20=EC=A7=80=EB=8F=84=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EB=A7=88?= =?UTF-8?q?=EC=BB=A4=20=ED=91=9C=EC=8B=9C=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.env.example | 1 + frontend/pnpm-workspace.yaml | 3 + frontend/src/api/http.js | 13 + frontend/src/api/properties.js | 18 + frontend/src/assets/main.css | 25 + frontend/src/store/mapStore.js | 339 +++++------ frontend/src/utils/naverMaps.js | 45 ++ frontend/src/views/MapExplorer.vue | 556 ++++++++++++------ .../property-map-fe/phase2-property-map-fe.md | 28 + 9 files changed, 654 insertions(+), 374 deletions(-) create mode 100644 frontend/src/api/http.js create mode 100644 frontend/src/api/properties.js create mode 100644 frontend/src/utils/naverMaps.js create mode 100644 phases/property-map-fe/phase2-property-map-fe.md diff --git a/frontend/.env.example b/frontend/.env.example index 4d18a04..94f4716 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1 +1,2 @@ VITE_NAVER_MAP_CLIENT_ID= +VITE_API_BASE_URL=http://localhost:8080 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index a23eae0..d9534f8 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,3 +1,6 @@ +packages: + - . + allowBuilds: esbuild: true vue-demi: true diff --git a/frontend/src/api/http.js b/frontend/src/api/http.js new file mode 100644 index 0000000..375b908 --- /dev/null +++ b/frontend/src/api/http.js @@ -0,0 +1,13 @@ +import axios from 'axios' + +const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080' + +const http = axios.create({ + baseURL, + timeout: 10000, + headers: { + Accept: 'application/json' + } +}) + +export default http diff --git a/frontend/src/api/properties.js b/frontend/src/api/properties.js new file mode 100644 index 0000000..2d7a71c --- /dev/null +++ b/frontend/src/api/properties.js @@ -0,0 +1,18 @@ +import http from './http' + +const cleanParams = (params) => + Object.fromEntries( + Object.entries(params).filter(([, value]) => value !== undefined && value !== null && value !== '') + ) + +export const fetchProperties = async (params) => { + const response = await http.get('/api/v1/properties', { + params: cleanParams(params) + }) + return response.data.data +} + +export const fetchPropertyDetail = async (propertyId) => { + const response = await http.get(`/api/v1/properties/${propertyId}`) + return response.data.data +} diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 50efcaa..992ee87 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -33,6 +33,31 @@ body { .form-input { @apply w-full bg-slate-50 border border-slate-200 rounded-xl p-2.5 text-xs font-bold focus:outline-none focus:ring-2 focus:ring-brand; } + + .filter-field { + @apply flex min-w-0 flex-col gap-1; + } + + .filter-field span { + @apply text-[10px] font-black text-slate-500; + } + + .filter-field input, + .filter-field select { + @apply h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-2 text-xs font-bold text-slate-800 outline-none transition focus:border-brand focus:ring-2 focus:ring-brand/20; + } + + .detail-stat { + @apply min-w-0 rounded-2xl border border-slate-200 bg-slate-50 p-3; + } + + .detail-stat span { + @apply block text-[10px] font-black text-slate-500; + } + + .detail-stat b { + @apply mt-1 block truncate text-sm font-black text-slate-900; + } } .glow-safe-path { filter: drop-shadow(0 0 6px #10b981); } diff --git a/frontend/src/store/mapStore.js b/frontend/src/store/mapStore.js index 5dafb08..896742c 100644 --- a/frontend/src/store/mapStore.js +++ b/frontend/src/store/mapStore.js @@ -1,241 +1,168 @@ import { defineStore } from 'pinia' +import { fetchProperties, fetchPropertyDetail } from '../api/properties' -const propertiesByRegion = { - gwanak: [ - { - id: 1, - region: 'gwanak', - name: '가온 리빙 하우스', - type: '원룸(다가구)', - deposit: 10000, - rent: 45, - marketPrice: 20000, - publicPrice: 12000, - seniorDeposits: 4000, - collateral: 3000, - address: '서울 관악구 대학동 1533-5', - safetyScore: 94, - crimeGrade: '1등급 (최상)', - cctvs: 8, - lamps: '최상', - policeDist: '80m', - description: '생활안전지도 기준 5대 범죄율 발생이 낮은 안심 치안 구역입니다. 야간 안심 조명이 보행로 전체에 확보되어 있습니다.', - x: 180, - y: 160, - historicalPrices: [8000, 9000, 9500, 10000], - reviews: [ - { user: '동글이', date: '2025.11', content: '여성안심귀갓길 바로 연결되어서 밤늦게 가도 골목이 밝아요.', rate: 5 }, - { user: '코딩새내기', date: '2026.01', content: '방음도 나쁘지 않고 편의점 접근성이 좋습니다. 다만 언덕길이 조금 있어요.', rate: 4 } - ] - }, - { - id: 2, - region: 'gwanak', - name: '대학동 그린 원룸', - type: '원룸(다가구)', - deposit: 12000, - rent: 38, - marketPrice: 15000, - publicPrice: 9000, - seniorDeposits: 5000, - collateral: 4000, - address: '서울 관악구 대학동 1511-12', - safetyScore: 68, - crimeGrade: '4등급 (보통)', - cctvs: 3, - lamps: '보통', - policeDist: '350m', - description: '가로등 조도가 낮고 CCTV 밀도가 낮습니다. 일부 범죄주의구간과 겹쳐 밤길 보행 시 큰 도로 위주 이동이 권장됩니다.', - x: 380, - y: 280, - historicalPrices: [10000, 11000, 11500, 12000], - reviews: [ - { user: '자취의신', date: '2025.08', content: '가격은 낮은 편이지만 밤에는 골목이 어두워요.', rate: 3 } - ] - }, - { - id: 3, - region: 'gwanak', - name: '샤로수 스마트 텔', - type: '오피스텔(공동주택)', - deposit: 16000, - rent: 65, - marketPrice: 22000, - publicPrice: 14000, - seniorDeposits: 0, - collateral: 2000, - address: '서울 관악구 청룡동 860-2', - safetyScore: 88, - crimeGrade: '2등급 (우수)', - cctvs: 12, - lamps: '우수', - policeDist: '150m', - description: '대로변에 위치해 조도가 균일하고 건물 내 보안 인프라가 우수합니다. 상권 밀집 구역과 가까워 주말 소음은 확인이 필요합니다.', - x: 250, - y: 320, - historicalPrices: [14000, 15000, 15500, 16000], - reviews: [ - { user: '안전제일', date: '2026.01', content: 'CCTV가 많고 관리가 잘 되어 택배 분실 걱정이 적습니다.', rate: 5 } - ] - } - ], - mapo: [ - { - id: 4, - region: 'mapo', - name: '홍대 가온 빌라', - type: '투룸(다세대주택)', - deposit: 18000, - rent: 60, - marketPrice: 24000, - publicPrice: 13000, - seniorDeposits: 0, - collateral: 3000, - address: '서울 마포구 서교동 365-4', - safetyScore: 72, - crimeGrade: '5등급 (유흥가 인접 우려)', - cctvs: 9, - lamps: '최상', - policeDist: '100m', - description: '파출소와 가로등은 가깝지만 주변 유흥 상권 밀도가 높아 주말 야간 소음과 경범죄 주의가 필요합니다.', - x: 200, - y: 200, - historicalPrices: [15000, 16000, 17500, 18000], - reviews: [ - { user: '홍대생', date: '2025.12', content: '밝기는 밝은데 주말 새벽에는 외부 소음이 있습니다.', rate: 3 } - ] - } - ] +const DEFAULT_BOUNDS = { + west: 126.76, + east: 127.18, + south: 37.42, + north: 37.66 } -const infraByRegion = { - gwanak: { - cctvs: [{ x: 100, y: 120 }, { x: 190, y: 150 }, { x: 210, y: 170 }, { x: 310, y: 290 }], - lamps: [{ x: 140, y: 80 }, { x: 230, y: 190 }, { x: 350, y: 220 }], - polices: [{ x: 150, y: 150, desc: '대학동안심치안센터' }], - paths: [{ x1: 50, y1: 50, x2: 150, y2: 150 }, { x1: 150, y1: 150, x2: 250, y2: 320 }], - crimeZones: ['130,220 180,240 220,180 150,170'], - mainRoads: [ - [50, 50, 450, 50], [150, 50, 150, 400], [150, 320, 450, 320] - ], - subRoads: [[300, 50, 300, 320], [50, 180, 150, 180]] - }, - mapo: { - cctvs: [{ x: 120, y: 150 }, { x: 220, y: 180 }], - lamps: [{ x: 150, y: 250 }, { x: 300, y: 100 }], - polices: [{ x: 210, y: 210, desc: '서교 파출소' }], - paths: [{ x1: 100, y1: 100, x2: 210, y2: 210 }], - crimeZones: ['180,180 250,190 240,250 170,240'], - mainRoads: [[50, 100, 450, 100], [210, 50, 210, 420]], - subRoads: [[50, 250, 450, 250]] - } +const DEFAULT_CENTER = { + latitude: 37.5665, + longitude: 126.978 +} + +const toNumberOrEmpty = (value) => { + if (value === '' || value === null || value === undefined) return '' + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : '' } export default defineStore('map', { state: () => ({ - currentRegion: 'gwanak', + currentRegion: 'seoul', selectedPropertyId: null, - favorites: [1, 3], + selectedProperty: null, + properties: [], + totalCount: 0, + bounds: { ...DEFAULT_BOUNDS }, + center: { ...DEFAULT_CENTER }, + zoom: 12, searchKeyword: '', - filterType: 'all', - sortType: 'safe', - activeLayers: { - cctv: true, - lamp: true, - police: true, - path: true, - crimeZone: true - }, - weights: { - safety: 40, - price: 30, - traffic: 20, - life: 10 + filters: { + transactionType: '', + propertyType: '', + minDeposit: '', + maxDeposit: '', + minPrice: '', + maxPrice: '' }, - calcBuildingType: 'multi', - communityPosts: [ - { id: 1, region: 'gwanak', title: '가온 리빙 하우스 뒤쪽 골목 방범등 수리 완료', category: '치안 및 안전', content: 'LED 보안등으로 교체되어 퇴근길 체감 조도가 확실히 좋아졌습니다.', author: '가온골목대장', rate: 5, date: '2026-05-20' }, - { id: 2, region: 'gwanak', title: '대학동 녹두거리 계단 지름길 밤길 주의', category: '실거주 꿀팁', content: '골목 일부가 어두워서 3분 더 걸려도 대로변 안심귀갓길로 우회하는 편이 좋습니다.', author: '치안순찰대', rate: 2, date: '2026-05-18' } - ], - chatMessages: [ - { role: 'bot', text: '안녕하세요! HUG 126% 기준, 다가구 선순위 보증금, 임대인 세금 체납 확인 등 안전 계약 질문을 도와드릴게요.' } - ] + isLoading: false, + isDetailLoading: false, + error: '', + detailError: '', + lastFetchedAt: null, + requestSeq: 0 }), getters: { regions: () => [ - { value: 'gwanak', label: '서울시 관악구 대학동', desc: '청년 원룸 밀집 구역' }, - { value: 'mapo', label: '서울시 마포구 서교동', desc: '유흥 상권 인접지' } + { + value: 'seoul', + label: '서울 주요 권역', + desc: '시드 매물 조회' + } ], - currentProperties: (state) => propertiesByRegion[state.currentRegion], - currentInfra: (state) => infraByRegion[state.currentRegion], - allProperties: () => Object.values(propertiesByRegion).flat(), - selectedProperty(state) { - return Object.values(propertiesByRegion).flat().find((property) => property.id === state.selectedPropertyId) || null - }, + currentProperties: (state) => state.properties, filteredProperties(state) { const keyword = state.searchKeyword.trim().toLowerCase() - let list = [...propertiesByRegion[state.currentRegion]] - if (keyword) { - list = list.filter((property) => property.name.toLowerCase().includes(keyword) || property.address.toLowerCase().includes(keyword)) - } - if (state.filterType !== 'all') { - list = list.filter((property) => property.type.includes(state.filterType)) - } - if (state.sortType === 'safe') list.sort((a, b) => b.safetyScore - a.safetyScore) - if (state.sortType === 'price-low') list.sort((a, b) => (a.deposit + a.rent * 100) - (b.deposit + b.rent * 100)) - return list - }, - communityForCurrentRegion(state) { - return state.communityPosts.filter((post) => post.region === state.currentRegion) + if (!keyword) return state.properties + return state.properties.filter((property) => { + const title = property.title || property.buildingName || '' + const address = property.address || property.roadAddress || '' + return title.toLowerCase().includes(keyword) || address.toLowerCase().includes(keyword) + }) }, regionStatus(state) { - if (state.currentRegion === 'gwanak') return { text: '치안 안전 최고 1등급지', tone: 'text-emerald-700' } - return { text: '유흥인접 폭력/경범죄 주의구간 포함', tone: 'text-rose-600' } + if (state.error) return { text: '매물 API 확인 필요', tone: 'text-rose-600' } + if (state.isLoading) return { text: '매물 불러오는 중', tone: 'text-slate-600' } + return { text: `${state.totalCount.toLocaleString()}개 매물 표시`, tone: 'text-emerald-700' } + }, + hasActiveFilters(state) { + return Object.values(state.filters).some((value) => value !== '') } }, actions: { setRegion(region) { this.currentRegion = region - this.selectedPropertyId = null }, - selectProperty(id) { - this.selectedPropertyId = id + setBounds(bounds) { + this.bounds = { + west: Number(bounds.west), + east: Number(bounds.east), + south: Number(bounds.south), + north: Number(bounds.north) + } }, - closeProperty() { - this.selectedPropertyId = null + setCenter(center) { + this.center = { + latitude: Number(center.latitude), + longitude: Number(center.longitude) + } }, - toggleLayer(layerKey) { - this.activeLayers[layerKey] = !this.activeLayers[layerKey] + setZoom(zoom) { + this.zoom = Number(zoom) }, - toggleFavorite(id) { - const index = this.favorites.indexOf(id) - if (index >= 0) this.favorites.splice(index, 1) - else this.favorites.push(id) + setFilter(key, value) { + if (!(key in this.filters)) return + if (['minDeposit', 'maxDeposit', 'minPrice', 'maxPrice'].includes(key)) { + this.filters[key] = toNumberOrEmpty(value) + return + } + this.filters[key] = value }, - setWeight(key, value) { - this.weights[key] = Number(value) + resetFilters() { + this.filters = { + transactionType: '', + propertyType: '', + minDeposit: '', + maxDeposit: '', + minPrice: '', + maxPrice: '' + } + this.searchKeyword = '' }, - addCommunityPost(payload) { - this.communityPosts.unshift({ - id: this.communityPosts.length + 1, - region: this.currentRegion, - date: new Date().toISOString().slice(0, 10), - ...payload - }) + async fetchProperties(bounds = this.bounds) { + const seq = ++this.requestSeq + this.isLoading = true + this.error = '' + this.setBounds(bounds) + + try { + const data = await fetchProperties({ + ...this.bounds, + ...this.filters + }) + + if (seq !== this.requestSeq) return + + this.properties = data.items || [] + this.totalCount = data.totalCount ?? this.properties.length + this.lastFetchedAt = new Date().toISOString() + + if (this.selectedPropertyId && !this.properties.some((property) => property.id === this.selectedPropertyId)) { + this.selectedPropertyId = null + this.selectedProperty = null + } + } catch (error) { + if (seq !== this.requestSeq) return + this.error = error.response?.data?.message || '매물 데이터를 불러오지 못했습니다.' + this.properties = [] + this.totalCount = 0 + } finally { + if (seq === this.requestSeq) { + this.isLoading = false + } + } }, - sendChat(text) { - const message = text.trim() - if (!message) return - this.chatMessages.push({ role: 'user', text: message }) - let answer = '다세대인지 다가구인지 등기 상태를 먼저 확인해 주세요. 이후 공시가격, 보증금, 선순위 권리, 세금 체납 여부를 함께 검토하면 위험도를 더 정확히 판단할 수 있습니다.' - if (message.includes('126') || message.includes('보증보험') || message.includes('HUG')) { - answer = 'HUG 126% 기준은 공시가격 × 140% × 90%로 계산합니다. 즉 보증금이 공시가격의 126%를 초과하면 반환보증 가입이 어려울 수 있어 계약 전 공시가격과 보증금을 반드시 비교해야 합니다.' - } else if (message.includes('다가구') || message.includes('선순위')) { - answer = '다가구 주택은 호실별 개별 등기가 아니라 건물 전체 기준으로 권리관계가 얽힙니다. 내 보증금보다 먼저 배당받는 기존 세입자 보증금 총합을 확인해야 하며, 선순위 임차인 정보 제공 확인서를 요구하는 것이 좋습니다.' - } else if (message.includes('세금') || message.includes('체납')) { - answer = '임대인의 국세·지방세 체납은 경매 배당에서 세입자보다 앞설 수 있습니다. 계약 전 국세 및 지방세 완납증명서 제출 또는 미납 국세 열람 동의를 특약으로 두는 편이 안전합니다.' + async selectProperty(id) { + this.selectedPropertyId = id + this.detailError = '' + this.selectedProperty = this.properties.find((property) => property.id === id) || null + this.isDetailLoading = true + + try { + this.selectedProperty = await fetchPropertyDetail(id) + } catch (error) { + this.detailError = error.response?.data?.message || '매물 상세 정보를 불러오지 못했습니다.' + } finally { + this.isDetailLoading = false } - this.chatMessages.push({ role: 'bot', text: answer }) + }, + closeProperty() { + this.selectedPropertyId = null + this.selectedProperty = null + this.detailError = '' } } }) diff --git a/frontend/src/utils/naverMaps.js b/frontend/src/utils/naverMaps.js new file mode 100644 index 0000000..1efe58c --- /dev/null +++ b/frontend/src/utils/naverMaps.js @@ -0,0 +1,45 @@ +let sdkPromise + +export const loadNaverMaps = () => { + if (typeof window === 'undefined') { + return Promise.reject(new Error('Naver Maps SDK can only be loaded in the browser.')) + } + + if (window.naver?.maps) { + return Promise.resolve(window.naver.maps) + } + + const clientId = import.meta.env.VITE_NAVER_MAP_CLIENT_ID + if (!clientId) { + return Promise.reject(new Error('VITE_NAVER_MAP_CLIENT_ID is not configured.')) + } + + if (sdkPromise) { + return sdkPromise + } + + sdkPromise = new Promise((resolve, reject) => { + const existingScript = document.getElementById('naver-map-sdk') + if (existingScript) { + existingScript.addEventListener('load', () => resolve(window.naver.maps), { once: true }) + existingScript.addEventListener('error', () => reject(new Error('Failed to load Naver Maps SDK.')), { once: true }) + return + } + + const script = document.createElement('script') + script.id = 'naver-map-sdk' + script.async = true + script.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${encodeURIComponent(clientId)}` + script.onload = () => { + if (window.naver?.maps) { + resolve(window.naver.maps) + return + } + reject(new Error('Naver Maps SDK loaded without maps namespace.')) + } + script.onerror = () => reject(new Error('Failed to load Naver Maps SDK.')) + document.head.appendChild(script) + }) + + return sdkPromise +} diff --git a/frontend/src/views/MapExplorer.vue b/frontend/src/views/MapExplorer.vue index 72b57f5..173f6e0 100644 --- a/frontend/src/views/MapExplorer.vue +++ b/frontend/src/views/MapExplorer.vue @@ -1,172 +1,214 @@ diff --git a/phases/property-map-fe/phase2-property-map-fe.md b/phases/property-map-fe/phase2-property-map-fe.md new file mode 100644 index 0000000..56b6127 --- /dev/null +++ b/phases/property-map-fe/phase2-property-map-fe.md @@ -0,0 +1,28 @@ +# Phase 2: Property Map Frontend + +## Goal +Connect the F-1 property search UI to the Spring Boot Property API and show returned properties as Naver Map markers. Keep the scope to map search, marker display, basic filters, and property detail lookup. + +## Files +- `frontend/.env.example` - document frontend API and Naver Maps environment variables. +- `frontend/src/api/http.js` - create the shared Axios client for Spring Boot REST calls. +- `frontend/src/api/properties.js` - add Property API request helpers. +- `frontend/src/utils/naverMaps.js` - load the Naver Maps SDK from `VITE_NAVER_MAP_CLIENT_ID`. +- `frontend/src/store/mapStore.js` - replace mock map properties with API-backed state and actions. +- `frontend/src/views/MapExplorer.vue` - render the Naver map, markers, filters, list, and detail panel. + +## Done When +- [ ] The frontend requests `GET /api/v1/properties` with current map bounds and active filters. +- [ ] Property markers are rendered on the Naver map and selecting a marker/list item requests `GET /api/v1/properties/{propertyId}`. +- [ ] Basic filters for transaction type, property type, deposit, and sale price are passed to the backend API. +- [ ] Missing map API key and backend API errors show user-readable empty/error states without crashing the page. +- [ ] `pnpm build` succeeds in `frontend`. + +## Architecture Rules +- Frontend calls only the Spring Boot REST API. +- API keys and backend URLs are read from environment variables. +- MVP-excluded features such as HUG precise judgment, community, brokerage, and AI registry analysis are not implemented in this phase. +- API response contracts follow `docs/08_API_SPEC.md`. + +## Implementation Instructions +Use `VITE_API_BASE_URL` for the Spring Boot base URL and `VITE_NAVER_MAP_CLIENT_ID` for the browser map SDK. Keep the initial map centered around the seeded Seoul property data, then fetch by map viewport after the SDK is ready and whenever the map becomes idle after movement or zoom. From a4612b5ebffd97bc954fdf5b6590bdff0a32fd85 Mon Sep 17 00:00:00 2001 From: HOKAGO-MEMORIES Date: Fri, 19 Jun 2026 01:15:08 +0900 Subject: [PATCH 2/5] =?UTF-8?q?docs(fe):=20=EB=A7=A4=EB=AC=BC=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=EC=9A=A9=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=98=88=EC=8B=9C=20=EC=B6=94=EA=B0=80=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 12 ++++++++++++ frontend/.env.example | 3 +++ 2 files changed, 15 insertions(+) diff --git a/.env.example b/.env.example index d18d823..cf291e3 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,17 @@ +# Backend / seed scripts +# Naver Maps geocoding uses the server-side client id and secret. NAVER_MAPS_CLIENT_ID= NAVER_MAPS_CLIENT_SECRET= + +# MOLIT transaction APIs. Used by seed/batch scripts, not by the frontend. MOLIT_SERVICE_KEY= + +# Spring Boot datasource. Use the Supabase PostgreSQL JDBC connection string. +# Example: +# SUPABASE_DB_URL=jdbc:postgresql://aws-0-ap-northeast-2.pooler.supabase.com:6543/postgres?sslmode=require SUPABASE_DB_URL= +SUPABASE_DB_USERNAME= +SUPABASE_DB_PASSWORD= + +# Supabase admin key for seed/admin scripts only. Never expose this to the frontend. SUPABASE_SERVICE_ROLE_KEY= diff --git a/frontend/.env.example b/frontend/.env.example index 94f4716..3d08556 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,5 @@ +# Browser-safe Naver Maps SDK key. Do not put the secret here. VITE_NAVER_MAP_CLIENT_ID= + +# Spring Boot API base URL. Keep this pointing at the backend when verifying map markers. VITE_API_BASE_URL=http://localhost:8080 From 0a85e92a7edcfd6fc6279520682ab5e24a29a39b Mon Sep 17 00:00:00 2001 From: HOKAGO-MEMORIES Date: Fri, 19 Jun 2026 01:29:26 +0900 Subject: [PATCH 3/5] =?UTF-8?q?docs(be):=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=98=88=EC=8B=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 +-------- backend/.env.example | 7 +++++++ 2 files changed, 8 insertions(+), 8 deletions(-) create mode 100644 backend/.env.example diff --git a/.env.example b/.env.example index cf291e3..0c47ab6 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Backend / seed scripts +# Seed scripts / local data generation # Naver Maps geocoding uses the server-side client id and secret. NAVER_MAPS_CLIENT_ID= NAVER_MAPS_CLIENT_SECRET= @@ -6,12 +6,5 @@ NAVER_MAPS_CLIENT_SECRET= # MOLIT transaction APIs. Used by seed/batch scripts, not by the frontend. MOLIT_SERVICE_KEY= -# Spring Boot datasource. Use the Supabase PostgreSQL JDBC connection string. -# Example: -# SUPABASE_DB_URL=jdbc:postgresql://aws-0-ap-northeast-2.pooler.supabase.com:6543/postgres?sslmode=require -SUPABASE_DB_URL= -SUPABASE_DB_USERNAME= -SUPABASE_DB_PASSWORD= - # Supabase admin key for seed/admin scripts only. Never expose this to the frontend. SUPABASE_SERVICE_ROLE_KEY= diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..6694315 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,7 @@ +# Spring Boot datasource +# Use these values when running the backend locally. +# Example: +# SUPABASE_DB_URL=jdbc:postgresql://aws-0-ap-northeast-2.pooler.supabase.com:6543/postgres?sslmode=require +SUPABASE_DB_URL= +SUPABASE_DB_USERNAME= +SUPABASE_DB_PASSWORD= From af0c72662200ec6b61fada64e6feb5009943993b Mon Sep 17 00:00:00 2001 From: HOKAGO-MEMORIES Date: Fri, 19 Jun 2026 01:44:53 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(be):=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=EB=A7=A4=EB=AC=BC=20API=20CORS=20=ED=97=88=EC=9A=A9=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 3 ++ .../com/ssafy/salmanhae/config/WebConfig.java | 32 +++++++++++++++++++ .../property/PropertyHttpIntegrationTest.java | 20 ++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 backend/src/main/java/com/ssafy/salmanhae/config/WebConfig.java diff --git a/backend/.env.example b/backend/.env.example index 6694315..f327370 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,3 +5,6 @@ SUPABASE_DB_URL= SUPABASE_DB_USERNAME= SUPABASE_DB_PASSWORD= + +# Comma-separated frontend origins allowed to call /api/** from browsers. +APP_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 diff --git a/backend/src/main/java/com/ssafy/salmanhae/config/WebConfig.java b/backend/src/main/java/com/ssafy/salmanhae/config/WebConfig.java new file mode 100644 index 0000000..623e1d8 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/config/WebConfig.java @@ -0,0 +1,32 @@ +package com.ssafy.salmanhae.config; + +import java.util.Arrays; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final String[] allowedOrigins; + + public WebConfig( + @Value("${app.cors.allowed-origins:http://localhost:5173,http://127.0.0.1:5173}") String allowedOrigins + ) { + this.allowedOrigins = Arrays.stream(allowedOrigins.split(",")) + .map(String::trim) + .filter(origin -> !origin.isBlank()) + .toArray(String[]::new); + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .maxAge(3600); + } +} diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyHttpIntegrationTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyHttpIntegrationTest.java index 0227dd9..1c5eefc 100644 --- a/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyHttpIntegrationTest.java +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/property/PropertyHttpIntegrationTest.java @@ -8,6 +8,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ActiveProfiles; @@ -51,4 +54,21 @@ void detailEndpointReturnsNotFoundForMissingProperty() { assertThat(notFoundResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(notFoundResponse.getBody()).containsEntry("code", "PROPERTY_NOT_FOUND"); } + + @Test + void propertyApiAllowsLocalFrontendOrigin() { + HttpHeaders headers = new HttpHeaders(); + headers.setOrigin("http://127.0.0.1:5173"); + headers.setAccessControlRequestMethod(HttpMethod.GET); + + ResponseEntity preflightResponse = restTemplate.exchange( + "/api/v1/properties?west=126.93&east=126.94&south=37.46&north=37.48", + HttpMethod.OPTIONS, + new HttpEntity<>(headers), + Void.class + ); + + assertThat(preflightResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(preflightResponse.getHeaders().getAccessControlAllowOrigin()).isEqualTo("http://127.0.0.1:5173"); + } } From 794690775e03843150623922a3e5cef380653258 Mon Sep 17 00:00:00 2001 From: HOKAGO-MEMORIES Date: Fri, 19 Jun 2026 02:10:28 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(fe):=20=EC=A7=80=EB=8F=84=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EB=A6=AC=EB=B7=B0=20=EB=B3=B4=EC=99=84=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 2 +- frontend/src/store/mapStore.js | 15 ++++-- frontend/src/utils/naverMaps.js | 49 ++++++++++++++++--- frontend/src/views/MapExplorer.vue | 3 ++ .../property-map-fe/phase2-property-map-fe.md | 3 +- 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index f327370..5ef0e9f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,9 +2,9 @@ # Use these values when running the backend locally. # Example: # SUPABASE_DB_URL=jdbc:postgresql://aws-0-ap-northeast-2.pooler.supabase.com:6543/postgres?sslmode=require +SUPABASE_DB_PASSWORD= SUPABASE_DB_URL= SUPABASE_DB_USERNAME= -SUPABASE_DB_PASSWORD= # Comma-separated frontend origins allowed to call /api/** from browsers. APP_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 diff --git a/frontend/src/store/mapStore.js b/frontend/src/store/mapStore.js index 896742c..e7a19d2 100644 --- a/frontend/src/store/mapStore.js +++ b/frontend/src/store/mapStore.js @@ -43,7 +43,8 @@ export default defineStore('map', { error: '', detailError: '', lastFetchedAt: null, - requestSeq: 0 + requestSeq: 0, + detailRequestSeq: 0 }), getters: { regions: () => [ @@ -146,23 +147,31 @@ export default defineStore('map', { } }, async selectProperty(id) { + const seq = ++this.detailRequestSeq this.selectedPropertyId = id this.detailError = '' this.selectedProperty = this.properties.find((property) => property.id === id) || null this.isDetailLoading = true try { - this.selectedProperty = await fetchPropertyDetail(id) + const property = await fetchPropertyDetail(id) + if (seq !== this.detailRequestSeq || this.selectedPropertyId !== id) return + this.selectedProperty = property } catch (error) { + if (seq !== this.detailRequestSeq || this.selectedPropertyId !== id) return this.detailError = error.response?.data?.message || '매물 상세 정보를 불러오지 못했습니다.' } finally { - this.isDetailLoading = false + if (seq === this.detailRequestSeq) { + this.isDetailLoading = false + } } }, closeProperty() { + this.detailRequestSeq += 1 this.selectedPropertyId = null this.selectedProperty = null this.detailError = '' + this.isDetailLoading = false } } }) diff --git a/frontend/src/utils/naverMaps.js b/frontend/src/utils/naverMaps.js index 1efe58c..c90381f 100644 --- a/frontend/src/utils/naverMaps.js +++ b/frontend/src/utils/naverMaps.js @@ -1,5 +1,18 @@ let sdkPromise +const resolveIfReady = (resolve) => { + if (window.naver?.maps) { + resolve(window.naver.maps) + return true + } + return false +} + +const rejectAndAllowRetry = (reject, message) => { + sdkPromise = null + reject(new Error(message)) +} + export const loadNaverMaps = () => { if (typeof window === 'undefined') { return Promise.reject(new Error('Naver Maps SDK can only be loaded in the browser.')) @@ -21,23 +34,47 @@ export const loadNaverMaps = () => { sdkPromise = new Promise((resolve, reject) => { const existingScript = document.getElementById('naver-map-sdk') if (existingScript) { - existingScript.addEventListener('load', () => resolve(window.naver.maps), { once: true }) - existingScript.addEventListener('error', () => reject(new Error('Failed to load Naver Maps SDK.')), { once: true }) + if (resolveIfReady(resolve)) return + + const scriptState = existingScript.dataset.loadState + const readyState = existingScript.readyState + if (scriptState === 'error' || scriptState === 'loaded' || readyState === 'complete') { + existingScript.remove() + } else { + existingScript.addEventListener('load', () => { + if (!resolveIfReady(resolve)) { + rejectAndAllowRetry(reject, 'Naver Maps SDK loaded without maps namespace.') + } + }, { once: true }) + existingScript.addEventListener('error', () => { + existingScript.dataset.loadState = 'error' + rejectAndAllowRetry(reject, 'Failed to load Naver Maps SDK.') + }, { once: true }) + return + } + } + + if (document.getElementById('naver-map-sdk')) { return } const script = document.createElement('script') script.id = 'naver-map-sdk' script.async = true + script.dataset.loadState = 'loading' script.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${encodeURIComponent(clientId)}` script.onload = () => { - if (window.naver?.maps) { - resolve(window.naver.maps) + script.dataset.loadState = 'loaded' + if (resolveIfReady(resolve)) { return } - reject(new Error('Naver Maps SDK loaded without maps namespace.')) + rejectAndAllowRetry(reject, 'Naver Maps SDK loaded without maps namespace.') + } + script.onerror = () => { + script.dataset.loadState = 'error' + script.remove() + rejectAndAllowRetry(reject, 'Failed to load Naver Maps SDK.') } - script.onerror = () => reject(new Error('Failed to load Naver Maps SDK.')) document.head.appendChild(script) }) diff --git a/frontend/src/views/MapExplorer.vue b/frontend/src/views/MapExplorer.vue index 173f6e0..bcc6407 100644 --- a/frontend/src/views/MapExplorer.vue +++ b/frontend/src/views/MapExplorer.vue @@ -129,7 +129,10 @@ 'cursor-pointer rounded-xl border p-3 transition', store.selectedPropertyId === property.id ? 'border-brand bg-brand-light shadow-sm' : 'border-slate-200 bg-slate-50 hover:bg-slate-100' ]" + tabindex="0" @click="store.selectProperty(property.id)" + @keydown.enter="store.selectProperty(property.id)" + @keydown.space.prevent="store.selectProperty(property.id)" >
diff --git a/phases/property-map-fe/phase2-property-map-fe.md b/phases/property-map-fe/phase2-property-map-fe.md index 56b6127..a8c0873 100644 --- a/phases/property-map-fe/phase2-property-map-fe.md +++ b/phases/property-map-fe/phase2-property-map-fe.md @@ -5,6 +5,7 @@ Connect the F-1 property search UI to the Spring Boot Property API and show retu ## Files - `frontend/.env.example` - document frontend API and Naver Maps environment variables. +- `frontend/pnpm-workspace.yaml` - correct the frontend pnpm workspace settings so local install/build commands work. - `frontend/src/api/http.js` - create the shared Axios client for Spring Boot REST calls. - `frontend/src/api/properties.js` - add Property API request helpers. - `frontend/src/utils/naverMaps.js` - load the Naver Maps SDK from `VITE_NAVER_MAP_CLIENT_ID`. @@ -25,4 +26,4 @@ Connect the F-1 property search UI to the Spring Boot Property API and show retu - API response contracts follow `docs/08_API_SPEC.md`. ## Implementation Instructions -Use `VITE_API_BASE_URL` for the Spring Boot base URL and `VITE_NAVER_MAP_CLIENT_ID` for the browser map SDK. Keep the initial map centered around the seeded Seoul property data, then fetch by map viewport after the SDK is ready and whenever the map becomes idle after movement or zoom. +Use `VITE_API_BASE_URL` for the Spring Boot base URL and `VITE_NAVER_MAP_CLIENT_ID` for the browser map SDK. Initialize the map with a fixed Seoul viewport centered at latitude `37.5665`, longitude `126.9780`, zoom `12`, and fallback bounds `west=126.76`, `east=127.18`, `south=37.42`, `north=37.66`. Fetch properties by the current map bounds after the SDK is ready and whenever the map becomes idle after movement or zoom. If the backend returns no properties, keep this fixed viewport and show the empty state rather than auto-fitting or panning to another region.