diff --git a/package.json b/package.json index f9ea390..2900597 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ ] }, "dependencies": { + "@stomp/stompjs": "^7.3.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.6", diff --git a/src/app/layouts/AppLayout.tsx b/src/app/layouts/AppLayout.tsx index 1ce0061..339d6b4 100644 --- a/src/app/layouts/AppLayout.tsx +++ b/src/app/layouts/AppLayout.tsx @@ -1,11 +1,15 @@ import { useEffect } from 'react'; -import { Outlet } from 'react-router-dom'; +import { Outlet, useLocation } from 'react-router-dom'; import { redirectToLogin, refreshAccessToken } from '@/shared/lib/auth/refreshSession'; import { getAccessToken, getRefreshToken, isAccessTokenExpired } from '@/shared/lib/auth/token'; import { Header } from '@/widgets/header'; export function AppLayout() { + const location = useLocation(); + // 산책 페이지는 헤더 없는 전체화면(지도) 레이아웃 + const isFullBleed = location.pathname.startsWith('/walk'); + useEffect(() => { if (!getAccessToken() || !getRefreshToken() || !isAccessTokenExpired()) { return; @@ -21,6 +25,16 @@ export function AppLayout() { void refreshSession(); }, []); + if (isFullBleed) { + return ( +
+
+ +
+
+ ); + } + return (
diff --git a/src/features/walk-fence/api/fence.ts b/src/features/walk-fence/api/fence.ts new file mode 100644 index 0000000..0344d35 --- /dev/null +++ b/src/features/walk-fence/api/fence.ts @@ -0,0 +1,50 @@ +import { apiClient } from '@/shared/api/axios'; + +import type { + CreateFenceRequest, + FenceBoundariesResponse, + FenceBoundaryResponse, + FenceMessageResponse, + FenceStatusResponse, + ToggleFenceRequest, + UpdateFenceRangeRequest, +} from '../model/types'; + +/** 1. 울타리 생성 */ +export async function createFence(payload: CreateFenceRequest): Promise { + const response = await apiClient.post('/fence/range', payload); + return response.data; +} + +/** 2. 특정 반려동물의 울타리 활성화 상태 조회 */ +export async function getFenceStatus(petId: number): Promise { + const response = await apiClient.get(`/fence/${petId}/status`); + return response.data; +} + +/** 3. 울타리 ON/OFF 변경 */ +export async function toggleFence(fenceId: number, payload: ToggleFenceRequest): Promise { + const response = await apiClient.patch(`/fence/${fenceId}/toggle`, payload); + return response.data; +} + +/** 4. 울타리 이름/중심/반경 수정 */ +export async function updateFenceRange( + fenceId: number, + payload: UpdateFenceRangeRequest, +): Promise { + const response = await apiClient.patch(`/fence/${fenceId}/range`, payload); + return response.data; +} + +/** 5. 지도에 표시할 단일 울타리 경계 조회 */ +export async function getFenceBoundary(fenceId: number): Promise { + const response = await apiClient.get(`/fence/${fenceId}/boundary`); + return response.data; +} + +/** 6. 접근 가능한 모든 울타리 경계 목록 조회 */ +export async function getFenceBoundaries(): Promise { + const response = await apiClient.get('/fence/boundaries'); + return response.data; +} diff --git a/src/features/walk-fence/index.ts b/src/features/walk-fence/index.ts new file mode 100644 index 0000000..fb196ad --- /dev/null +++ b/src/features/walk-fence/index.ts @@ -0,0 +1,29 @@ +export { + createFence, + getFenceBoundaries, + getFenceBoundary, + getFenceStatus, + toggleFence, + updateFenceRange, +} from './api/fence'; +export { useCreateFence } from './model/useCreateFence'; +export { useFenceBoundaries } from './model/useFenceBoundaries'; +export { useFenceStatus } from './model/useFenceStatus'; +export { useLiveLocation } from './model/useLiveLocation'; +export { useToggleFence } from './model/useToggleFence'; +export { useUpdateFenceRange } from './model/useUpdateFenceRange'; +export { FenceControlPanel } from './ui/FenceControlPanel'; +export { WalkMap } from './ui/WalkMap'; +export type { + CreateFenceRequest, + FenceBoundariesResponse, + FenceBoundary, + FenceBoundaryResponse, + FenceCenter, + FenceMessageResponse, + FenceStatusResponse, + LiveLocationMessage, // 실시간 위치 업데이트 서버 메세지 전체 받기 + LiveLocationPayload, // 실시간 위치 업데이트 서버 메세지 중 payload 부분 + ToggleFenceRequest, + UpdateFenceRangeRequest, +} from './model/types'; diff --git a/src/features/walk-fence/model/types.ts b/src/features/walk-fence/model/types.ts new file mode 100644 index 0000000..ae44827 --- /dev/null +++ b/src/features/walk-fence/model/types.ts @@ -0,0 +1,88 @@ +// 울타리(지오펜스) REST API 요청/응답 타입 + +/** 좌표 (위도/경도) */ +export interface FenceCenter { + latitude: number; + longitude: number; +} + +/** 공통 메시지 응답 */ +export interface FenceMessageResponse { + message: string; +} + +/** 1. 울타리 생성 — POST /fence/range */ +export interface CreateFenceRequest { + petId: number; + centerLatitude: number; + centerLongitude: number; + /** 반경(미터) */ + radius: number; + fenceName: string; +} + +/** 2. 울타리 상태 조회 — GET /fence/{petId}/status */ +export interface FenceStatusResponse { + message: string; + isActive: boolean; +} + +/** 3. 울타리 ON/OFF — PATCH /fence/{fenceId}/toggle */ +export interface ToggleFenceRequest { + fenceIsActive: boolean; +} + +/** 4. 울타리 범위 수정 — PATCH /fence/{fenceId}/range (모든 필드 선택) */ +export interface UpdateFenceRangeRequest { + centerLatitude?: number; + centerLongitude?: number; + fenceName?: string; + radius?: number; +} + +/** 5. 울타리 경계 조회 — GET /fence/{fenceId}/boundary */ +export interface FenceBoundaryResponse { + message: string; + center: FenceCenter; + radius: number; + fenceId: number; +} + +/** 6-1. 울타리 경계 목록의 단일 항목 */ +export interface FenceBoundary { + fenceId: number; + fenceName: string; + center: FenceCenter; + radius: number; + isActive: boolean; + petId: number; + petName: string; + petImageUrl: string; +} + +/** 6. 울타리 경계 목록 조회 — GET /fence/boundaries */ +export interface FenceBoundariesResponse { + message: string; + boundaries: FenceBoundary[]; +} + +// 실시간 위치 업데이트 WebSocket 메시지 타입 +/** 실시간 위치 메시지의 payload (서버가 울타리 판정까지 해서 보냄) */ +export interface LiveLocationPayload { + petId: number; + latitude: number; + longitude: number; + measuredAt: string; + /** 울타리 안에 있는지 (서버 판정) */ + insideFence: boolean; + /** 울타리 중심에서의 거리(미터) */ + distanceMeter: number; + radius: number; + message: string; +} + +/** WebSocket 수신 메시지 (payload가 한 겹 감싸져 있음) */ +export interface LiveLocationMessage { + type: string; + payload: LiveLocationPayload; +} diff --git a/src/features/walk-fence/model/useCreateFence.ts b/src/features/walk-fence/model/useCreateFence.ts new file mode 100644 index 0000000..5940b09 --- /dev/null +++ b/src/features/walk-fence/model/useCreateFence.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { createFence } from '../api/fence'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +/** 울타리 생성 후 경계 목록 갱신 */ +export function useCreateFence() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createFence, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundaries() }); + }, + }); +} diff --git a/src/features/walk-fence/model/useFenceBoundaries.ts b/src/features/walk-fence/model/useFenceBoundaries.ts new file mode 100644 index 0000000..50a6d5d --- /dev/null +++ b/src/features/walk-fence/model/useFenceBoundaries.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getFenceBoundaries } from '../api/fence'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +/** 접근 가능한 모든 울타리 경계 목록 조회 */ +export function useFenceBoundaries() { + return useQuery({ + queryKey: queryKeys.fence.boundaries(), + queryFn: getFenceBoundaries, + }); +} diff --git a/src/features/walk-fence/model/useFenceStatus.ts b/src/features/walk-fence/model/useFenceStatus.ts new file mode 100644 index 0000000..b5d9eb8 --- /dev/null +++ b/src/features/walk-fence/model/useFenceStatus.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getFenceStatus } from '../api/fence'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +/** 특정 반려동물의 울타리 활성화 상태 조회 (petId 없으면 비활성) */ +export function useFenceStatus(petId: number | null) { + return useQuery({ + queryKey: queryKeys.fence.status(petId ?? 0), + queryFn: () => getFenceStatus(petId as number), + enabled: petId != null, + }); +} diff --git a/src/features/walk-fence/model/useLiveLocation.ts b/src/features/walk-fence/model/useLiveLocation.ts new file mode 100644 index 0000000..4d16b3d --- /dev/null +++ b/src/features/walk-fence/model/useLiveLocation.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; + +import { createStompClient } from '@/shared/lib/socket/stompClient'; + +import type { LiveLocationMessage, LiveLocationPayload } from './types'; + +/** 특정 펫의 실시간 위치를 구독 (petId 없으면 연결 안 함) */ +export function useLiveLocation(petId: number | null) { + const [location, setLocation] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + // petId가 바뀌면 이전 펫 위치를 초기화 (렌더 중 조정 — effect 내 setState 회피) + const [trackedPetId, setTrackedPetId] = useState(petId); + if (petId !== trackedPetId) { + setTrackedPetId(petId); + setLocation(null); + setIsConnected(false); + } + + useEffect(() => { + if (petId == null) return; + + const client = createStompClient(); + + // 연결 성공 시 구독 시작 + client.onConnect = () => { + setIsConnected(true); + client.subscribe(`/sub/fence/location/${petId}`, (frame) => { + try { + const message = JSON.parse(frame.body) as LiveLocationMessage; + setLocation(message.payload); // 감싸진 payload만 꺼내 저장 + } catch { + // JSON 파싱 실패는 무시 + } + }); + }; + + // 연결 끊기면 표시 + client.onWebSocketClose = () => { + setIsConnected(false); + }; + + client.activate(); // 연결 시작 + + // 언마운트 / petId 변경 시 연결 해제 + return () => { + void client.deactivate(); + }; + }, [petId]); + + return { location, isConnected }; +} diff --git a/src/features/walk-fence/model/useToggleFence.ts b/src/features/walk-fence/model/useToggleFence.ts new file mode 100644 index 0000000..9066442 --- /dev/null +++ b/src/features/walk-fence/model/useToggleFence.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { toggleFence } from '../api/fence'; +import type { ToggleFenceRequest } from './types'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +interface ToggleFenceVariables { + fenceId: number; + payload: ToggleFenceRequest; +} + +/** 울타리 ON/OFF 변경 후 경계 목록 갱신 */ +export function useToggleFence() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ fenceId, payload }: ToggleFenceVariables) => toggleFence(fenceId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundaries() }); + }, + }); +} diff --git a/src/features/walk-fence/model/useUpdateFenceRange.ts b/src/features/walk-fence/model/useUpdateFenceRange.ts new file mode 100644 index 0000000..632aa5b --- /dev/null +++ b/src/features/walk-fence/model/useUpdateFenceRange.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { updateFenceRange } from '../api/fence'; +import type { UpdateFenceRangeRequest } from './types'; +import { queryKeys } from '@/shared/lib/react-query/queryKey'; + +interface UpdateFenceRangeVariables { + fenceId: number; + payload: UpdateFenceRangeRequest; +} + +/** 울타리 이름/중심/반경 수정 후 관련 쿼리 갱신 */ +export function useUpdateFenceRange() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ fenceId, payload }: UpdateFenceRangeVariables) => updateFenceRange(fenceId, payload), + onSuccess: (_data, { fenceId }) => { + queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundaries() }); + queryClient.invalidateQueries({ queryKey: queryKeys.fence.boundary(fenceId) }); + }, + }); +} diff --git a/src/features/walk-fence/ui/FenceControlPanel.tsx b/src/features/walk-fence/ui/FenceControlPanel.tsx new file mode 100644 index 0000000..2d2ba68 --- /dev/null +++ b/src/features/walk-fence/ui/FenceControlPanel.tsx @@ -0,0 +1,167 @@ +import type { PetListItem } from '@/features/auth'; + +import type { FenceBoundary } from '../model/types'; + +interface DraftCenter { + lat: number; + lng: number; +} + +interface FenceControlPanelProps { + pets: PetListItem[]; + selectedPetId: number | null; + onSelectPet: (petId: number) => void; + /** 선택한 펫의 기존 울타리 (없으면 null → 생성 모드) */ + existingFence: FenceBoundary | null; + draftCenter: DraftCenter | null; + radius: number; + onRadiusChange: (radius: number) => void; + fenceName: string; + onFenceNameChange: (name: string) => void; + onCreate: () => void; + onUpdate: () => void; + onToggle: () => void; + isSubmitting: boolean; +} + +export function FenceControlPanel({ + pets, + selectedPetId, + onSelectPet, + existingFence, + draftCenter, + radius, + onRadiusChange, + fenceName, + onFenceNameChange, + onCreate, + onUpdate, + onToggle, + isSubmitting, +}: FenceControlPanelProps) { + const selectedPet = pets.find((pet) => pet.petId === selectedPetId) ?? null; + + return ( +
+

울타리 설정

+ + {/* 펫 선택 */} +
+

반려동물 선택

+ {pets.length === 0 ? ( +

등록된 반려동물이 없어요.

+ ) : ( +
+ {pets.map((pet) => { + const isActive = pet.petId === selectedPetId; + return ( + + ); + })} +
+ )} +
+ + {selectedPet && ( + <> + {/* 안내 */} +

+ 지도를 클릭해 울타리 중심을 {existingFence ? '옮길' : '지정할'} 수 있어요. + {draftCenter + ? ` 현재: ${draftCenter.lat.toFixed(5)}, ${draftCenter.lng.toFixed(5)}` + : existingFence + ? ' (지금은 기존 중심 유지)' + : ' (아직 미지정)'} +

+ + {/* 이름 (생성/수정 공통) */} + + + {/* 반경 */} + + + {/* 기존 울타리: 상태 + 수정 / 없으면: 생성 */} + {existingFence ? ( +
+
+ + 울타리 {existingFence.isActive ? '켜짐' : '꺼짐'} + + +
+ +
+ ) : ( + + )} + + )} +
+ ); +} diff --git a/src/features/walk-fence/ui/WalkMap.tsx b/src/features/walk-fence/ui/WalkMap.tsx new file mode 100644 index 0000000..ae61368 --- /dev/null +++ b/src/features/walk-fence/ui/WalkMap.tsx @@ -0,0 +1,196 @@ +import { useEffect, useRef, useState } from 'react'; + +import { loadNaverMap } from '@/shared/lib/naver-map/loadNaverMap'; + +import type { FenceBoundary } from '../model/types'; + +interface DraftCenter { + lat: number; + lng: number; +} + +interface WalkMapProps { + /** 지도에 그릴 기존 울타리 목록 */ + boundaries: FenceBoundary[]; + /** 생성/수정 미리보기용 중심 (없으면 미리보기 원 숨김) */ + draftCenter: DraftCenter | null; + /** 미리보기 원 반경(미터) */ + draftRadius: number; + /** 지도 클릭 시 좌표 콜백 */ + onMapClick: (lat: number, lng: number) => void; + /** 실시간 펫 위치 (없으면 마커 숨김) */ + livePosition: { lat: number; lng: number; insideFence: boolean } | null; +} + +const DEFAULT_CENTER = { lat: 37.5796, lng: 126.977 }; // 경복궁 + +// HTML 마커에 펫 이름을 넣기 전 간단한 이스케이프 (XSS 방지) +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function petLabelContent(name: string, isActive: boolean): string { + const color = isActive ? '#16a34a' : '#6b7280'; + return `
${escapeHtml(name)}
`; +} + +// 실시간 위치 점 마커 (울타리 안=초록, 밖=빨강) +function liveMarkerContent(insideFence: boolean): string { + const color = insideFence ? '#22c55e' : '#ef4444'; + return `
`; +} + +export function WalkMap({ boundaries, draftCenter, draftRadius, onMapClick, livePosition }: WalkMapProps) { + const mapElementRef = useRef(null); + const mapInstanceRef = useRef(null); + const circlesRef = useRef([]); // 기존 울타리 원들 + const markersRef = useRef([]); // 펫 이름 라벨들 + const draftCircleRef = useRef(null); // 미리보기 원 + const liveMarkerRef = useRef(null); // 실시간 위치 마커 + const onMapClickRef = useRef(onMapClick); // 항상 최신 콜백 보관 + const [isMapReady, setIsMapReady] = useState(false); + const [error, setError] = useState(null); + + // 리스너를 다시 달지 않고도 최신 onMapClick을 부르도록 ref만 갱신 + useEffect(() => { + onMapClickRef.current = onMapClick; + }, [onMapClick]); + + // 1. 지도 생성 + 클릭 리스너 (최초 1회) + useEffect(() => { + let canceled = false; + + loadNaverMap() + .then(() => { + if (canceled || !mapElementRef.current) return; + + const map = new naver.maps.Map(mapElementRef.current, { + center: new naver.maps.LatLng(DEFAULT_CENTER.lat, DEFAULT_CENTER.lng), + zoom: 15, + }); + + // 지도를 클릭하면 그 좌표를 콜백으로 올려보냄 + map.addListener('click', (event) => { + onMapClickRef.current(event.coord.lat(), event.coord.lng()); + }); + + mapInstanceRef.current = map; + setIsMapReady(true); + }) + .catch((e: unknown) => { + if (!canceled) setError(e instanceof Error ? e.message : '지도를 불러오지 못했습니다.'); + }); + + return () => { + canceled = true; + mapInstanceRef.current?.destroy(); + mapInstanceRef.current = null; + }; + }, []); + + // 2. 기존 울타리 원 + 펫 이름 라벨 그리기 (데이터 바뀔 때마다) + useEffect(() => { + const map = mapInstanceRef.current; + if (!isMapReady || !map) return; + + // 이전 원/라벨 제거 + circlesRef.current.forEach((circle) => circle.setMap(null)); + circlesRef.current = []; + markersRef.current.forEach((marker) => marker.setMap(null)); + markersRef.current = []; + + boundaries.forEach((fence) => { + const center = new naver.maps.LatLng(fence.center.latitude, fence.center.longitude); + + const circle = new naver.maps.Circle({ + map, + center, + radius: fence.radius, + strokeColor: fence.isActive ? '#22c55e' : '#9ca3af', // 활성=초록, 비활성=회색 + strokeWeight: 2, + fillColor: fence.isActive ? '#22c55e' : '#9ca3af', + fillOpacity: 0.15, + }); + circlesRef.current.push(circle); + + // 울타리 중심에 펫 이름 라벨 + const marker = new naver.maps.Marker({ + map, + position: center, + icon: { + content: petLabelContent(fence.petName, fence.isActive), + anchor: new naver.maps.Point(0, 0), + }, + }); + markersRef.current.push(marker); + }); + }, [isMapReady, boundaries]); + + // 3. 미리보기(점선) 원 그리기/갱신 + useEffect(() => { + const map = mapInstanceRef.current; + if (!isMapReady || !map) return; + + // 중심이 없으면 미리보기 원 제거 + if (!draftCenter) { + draftCircleRef.current?.setMap(null); + draftCircleRef.current = null; + return; + } + + const center = new naver.maps.LatLng(draftCenter.lat, draftCenter.lng); + + if (draftCircleRef.current) { + // 이미 있으면 위치/반경만 갱신 + draftCircleRef.current.setCenter(center); + draftCircleRef.current.setRadius(draftRadius); + } else { + draftCircleRef.current = new naver.maps.Circle({ + map, + center, + radius: draftRadius, + strokeColor: '#f59e0b', + strokeWeight: 2, + strokeStyle: 'shortdash', + fillColor: '#f59e0b', + fillOpacity: 0.12, + }); + } + }, [isMapReady, draftCenter, draftRadius]); + + // 3-1. 지도 중심 이동은 draftCenter가 바뀔 때만 (반경 조절 시 스냅 방지) + useEffect(() => { + const map = mapInstanceRef.current; + if (!isMapReady || !map || !draftCenter) return; + map.setCenter(new naver.maps.LatLng(draftCenter.lat, draftCenter.lng)); + }, [isMapReady, draftCenter]); + + // 4. 실시간 펫 위치 마커 (위치 올 때마다 갱신) + useEffect(() => { + const map = mapInstanceRef.current; + if (!isMapReady || !map) return; + + // 이전 마커 제거 후 다시 그림 (마커 1개라 부담 없음) + liveMarkerRef.current?.setMap(null); + liveMarkerRef.current = null; + + if (!livePosition) return; + + liveMarkerRef.current = new naver.maps.Marker({ + map, + position: new naver.maps.LatLng(livePosition.lat, livePosition.lng), + icon: { + content: liveMarkerContent(livePosition.insideFence), + anchor: new naver.maps.Point(0, 0), + }, + }); + }, [isMapReady, livePosition]); + + if (error) { + return ( +
{error}
+ ); + } + + return
; +} diff --git a/src/pages/walk/WalkPage.tsx b/src/pages/walk/WalkPage.tsx index 60e0061..eb82bd6 100644 --- a/src/pages/walk/WalkPage.tsx +++ b/src/pages/walk/WalkPage.tsx @@ -1,3 +1,161 @@ +import { useMemo, useState } from 'react'; + +import { usePetList } from '@/features/auth'; +import { + FenceControlPanel, + WalkMap, + useCreateFence, + useFenceBoundaries, + useLiveLocation, + useToggleFence, + useUpdateFenceRange, +} from '@/features/walk-fence'; + +import { WalkSideRail } from './ui/WalkSideRail'; + +interface DraftCenter { + lat: number; + lng: number; +} + +const EMPTY_BOUNDARIES: never[] = []; + export function WalkPage() { - return
산책 (뼈대)
; + const { data: boundariesData } = useFenceBoundaries(); + const { data: petListData } = usePetList(); + const createFence = useCreateFence(); + const toggleFence = useToggleFence(); + const updateFenceRange = useUpdateFenceRange(); + + const boundaries = boundariesData?.boundaries ?? EMPTY_BOUNDARIES; + const pets = petListData?.pets ?? []; + + const [selectedPetId, setSelectedPetId] = useState(null); + const [draftCenter, setDraftCenter] = useState(null); + const [radius, setRadius] = useState(500); + const [fenceName, setFenceName] = useState(''); + + // 선택한 펫의 기존 울타리 (있으면 수정 모드, 없으면 생성 모드) + const existingFence = useMemo( + () => (selectedPetId != null ? (boundaries.find((fence) => fence.petId === selectedPetId) ?? null) : null), + [boundaries, selectedPetId], + ); + + // 펫/울타리가 바뀌면 입력값 동기화 (effect 대신 렌더 중 조정 — React 권장 패턴) + const formKey = `${selectedPetId ?? ''}:${existingFence?.fenceId ?? ''}`; + const [syncedKey, setSyncedKey] = useState(formKey); + if (formKey !== syncedKey) { + setSyncedKey(formKey); + setRadius(existingFence ? existingFence.radius : 500); + setFenceName(existingFence ? existingFence.fenceName : ''); + setDraftCenter(null); + } + + const isSubmitting = createFence.isPending || toggleFence.isPending || updateFenceRange.isPending; + + const handleCreate = () => { + if (selectedPetId == null || !draftCenter) return; + createFence.mutate( + { + petId: selectedPetId, + centerLatitude: draftCenter.lat, + centerLongitude: draftCenter.lng, + radius, + fenceName: fenceName.trim(), + }, + { + onSuccess: () => setDraftCenter(null), // 저장 후 미리보기 원 제거 + }, + ); + }; + + const handleUpdate = () => { + if (!existingFence) return; + updateFenceRange.mutate( + { + fenceId: existingFence.fenceId, + payload: { + centerLatitude: draftCenter?.lat ?? existingFence.center.latitude, + centerLongitude: draftCenter?.lng ?? existingFence.center.longitude, + radius, + fenceName: fenceName.trim() || existingFence.fenceName, + }, + }, + { + onSuccess: () => setDraftCenter(null), // 저장 후 미리보기 원 제거 + }, + ); + }; + + const handleToggle = () => { + if (!existingFence) return; + toggleFence.mutate({ + fenceId: existingFence.fenceId, + payload: { fenceIsActive: !existingFence.isActive }, + }); + }; + + // 선택한 펫의 실시간 위치 구독 + const { location: liveLocation } = useLiveLocation(selectedPetId); + const selectedPetName = pets.find((pet) => pet.petId === selectedPetId)?.petName ?? '반려동물'; + + // 울타리가 켜져 있고 + 실제로 벗어났을 때만 이탈로 간주 + const fenceActive = existingFence?.isActive ?? false; + const isOutsideFence = !!liveLocation && fenceActive && !liveLocation.insideFence; + + return ( + // 네이버 지도 스타일: 지도가 화면 전체, 그 위에 둥근 레일 + 패널이 떠 있음 +
+ {/* 배경 전체를 채우는 지도 */} +
+ setDraftCenter({ lat, lng })} + livePosition={ + liveLocation + ? { lat: liveLocation.latitude, lng: liveLocation.longitude, insideFence: !isOutsideFence } + : null + } + /> +
+ + {/* 울타리 이탈 알림 배너 (울타리 켜진 경우에만) */} + {liveLocation && isOutsideFence && ( +
+ ⚠️ {selectedPetName}이(가) 울타리를 벗어났어요! (약 {Math.round(liveLocation.distanceMeter)}m) +
+ )} + + {/* 좌측 플로팅: 세로 레일 + 울타리 패널 */} +
+ + + +
+
+ ); } diff --git a/src/pages/walk/ui/WalkSideRail.tsx b/src/pages/walk/ui/WalkSideRail.tsx new file mode 100644 index 0000000..5aa03e3 --- /dev/null +++ b/src/pages/walk/ui/WalkSideRail.tsx @@ -0,0 +1,122 @@ +import { Link, NavLink } from 'react-router-dom'; + +import profileDefaultIllustration from '@/features/auth/assets/profile-default.svg'; +import { useCurrentUser } from '@/features/auth/model/useCurrentUser'; +import DoDoLogo from '@/shared/assets/images/Logo_light.svg?react'; +import { useIsLoggedIn } from '@/widgets/header'; + +interface IconProps { + className?: string; +} + +function PawIcon({ className }: IconProps) { + return ( + + + + + + + + ); +} + +function ChatIcon({ className }: IconProps) { + return ( + + + + ); +} + +function UserIcon({ className }: IconProps) { + return ( + + + + + ); +} + +const NAV_ITEMS = [ + { to: '/walk', label: '산책', Icon: PawIcon }, + { to: '/community', label: '커뮤니티', Icon: ChatIcon }, + { to: '/my', label: '마이도도', Icon: UserIcon }, +]; + +const itemClass = ({ isActive }: { isActive: boolean }) => + [ + 'flex w-full flex-col items-center gap-1 rounded-xl py-2 text-[11px] font-medium transition-colors', + isActive ? 'bg-brand/10 text-brand' : 'text-neutral-500 hover:bg-neutral-100 hover:text-neutral-800', + ].join(' '); + +function RailProfile() { + const { profileUrl } = useCurrentUser(); + const resolvedUrl = profileUrl?.trim() || profileDefaultIllustration; + + return ( + + + + ); +} + +export function WalkSideRail() { + const isLoggedIn = useIsLoggedIn(); + + return ( + + ); +} diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts index 33dac8a..e30ec22 100644 --- a/src/shared/config/env.ts +++ b/src/shared/config/env.ts @@ -2,6 +2,8 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; const NAVER_CLIENT_ID = import.meta.env.VITE_NAVER_CLIENT_ID; const OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI; +const NAVER_MAP_CLIENT_ID = import.meta.env.VITE_NAVER_MAP_CLIENT_ID; +const WS_URL = import.meta.env.VITE_WS_URL; // 환경 변수 누락 방지 const requiredEnv = { @@ -9,6 +11,8 @@ const requiredEnv = { VITE_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID, VITE_NAVER_CLIENT_ID: NAVER_CLIENT_ID, VITE_OAUTH_REDIRECT_URI: OAUTH_REDIRECT_URI, + VITE_NAVER_MAP_CLIENT_ID: NAVER_MAP_CLIENT_ID, + VITE_WS_URL: WS_URL, }; Object.entries(requiredEnv).forEach(([key, value]) => { @@ -22,4 +26,6 @@ export const env = { GOOGLE_CLIENT_ID, NAVER_CLIENT_ID, OAUTH_REDIRECT_URI, + NAVER_MAP_CLIENT_ID, + WS_URL, }; diff --git a/src/shared/lib/naver-map/loadNaverMap.ts b/src/shared/lib/naver-map/loadNaverMap.ts new file mode 100644 index 0000000..04fee39 --- /dev/null +++ b/src/shared/lib/naver-map/loadNaverMap.ts @@ -0,0 +1,26 @@ +import { env } from '@/shared/config'; + +let loadPromise: Promise | null = null; + +/** 네이버 지도 스크립트를 한 번만 로드하고, 완료 시 resolve */ +export function loadNaverMap(): Promise { + // 이미 로드 완료된 경우 + if (window.naver?.maps) return Promise.resolve(); + + // 로딩 중이면 같은 Promise 재사용 (중복 로드 방지) + if (loadPromise) return loadPromise; + + loadPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${env.NAVER_MAP_CLIENT_ID}`; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => { + loadPromise = null; // 실패 시 다음에 재시도 가능하도록 초기화 + reject(new Error('네이버 지도 스크립트를 불러오지 못했습니다.')); + }; + document.head.appendChild(script); + }); + + return loadPromise; +} diff --git a/src/shared/lib/naver-map/naver.d.ts b/src/shared/lib/naver-map/naver.d.ts new file mode 100644 index 0000000..872927c --- /dev/null +++ b/src/shared/lib/naver-map/naver.d.ts @@ -0,0 +1,78 @@ +// 네이버 지도(NCP Maps) 전역 타입 — 우선 지도 표시에 필요한 최소만 선언 +declare namespace naver.maps { + class LatLng { + constructor(lat: number, lng: number); + lat(): number; + lng(): number; + } + + interface MapOptions { + center: LatLng; + zoom?: number; + } + + /** 지도 이벤트 핸들 (해제 시 사용) */ + type MapEventListener = object; + + /** 클릭 등 포인터 이벤트 — coord에 클릭 좌표가 담김 */ + interface PointerEvent { + coord: LatLng; + } + + class Map { + constructor(element: string | HTMLElement, options: MapOptions); + setCenter(latlng: LatLng): void; + setZoom(zoom: number): void; + addListener(eventName: string, listener: (event: PointerEvent) => void): MapEventListener; + destroy(): void; + } + + interface CircleOptions { + map?: Map; + center: LatLng; + /** 반경(미터) */ + radius: number; + strokeColor?: string; + strokeWeight?: number; + strokeOpacity?: number; + /** 'solid' | 'shortdash' | 'dash' 등 */ + strokeStyle?: string; + fillColor?: string; + fillOpacity?: number; + } + + class Circle { + constructor(options: CircleOptions); + setMap(map: Map | null): void; + setCenter(center: LatLng): void; + setRadius(radius: number): void; + } + + class Point { + constructor(x: number, y: number); + } + + interface MarkerIcon { + content: string; + anchor?: Point; + } + + interface MarkerOptions { + map?: Map; + position: LatLng; + icon?: MarkerIcon | string; + title?: string; + clickable?: boolean; + } + + class Marker { + constructor(options: MarkerOptions); + setMap(map: Map | null): void; + setPosition(position: LatLng): void; + } +} + +// window.naver 로 접근할 수 있게 augment +interface Window { + naver: typeof naver; +} diff --git a/src/shared/lib/react-query/queryKey.ts b/src/shared/lib/react-query/queryKey.ts index 1fb92b5..ae3d569 100644 --- a/src/shared/lib/react-query/queryKey.ts +++ b/src/shared/lib/react-query/queryKey.ts @@ -60,4 +60,9 @@ export const queryKeys = { params?.sort ?? 'petWeightsMeasuredAt,desc', ] as const, }, + fence: { + boundaries: () => ['fence', 'boundaries'] as const, + boundary: (fenceId: number) => ['fence', fenceId, 'boundary'] as const, + status: (petId: number) => ['fence', petId, 'status'] as const, + }, } as const; diff --git a/src/shared/lib/socket/stompClient.ts b/src/shared/lib/socket/stompClient.ts new file mode 100644 index 0000000..748ef8d --- /dev/null +++ b/src/shared/lib/socket/stompClient.ts @@ -0,0 +1,23 @@ +import { Client } from '@stomp/stompjs'; + +import { env } from '@/shared/config'; +import { getAccessToken } from '@/shared/lib/auth/token'; + +/** 설정이 끝난 STOMP 클라이언트를 생성 (구독은 사용하는 쪽에서) */ +export function createStompClient(): Client { + const client = new Client({ + brokerURL: env.WS_URL, // wss://... 로 직접 연결 (SockJS 미사용) + reconnectDelay: 5000, // 연결 끊기면 5초 후 자동 재연결 + heartbeatIncoming: 10000, // 서버 ↔ 클라이언트 연결 살아있는지 확인(10초) + heartbeatOutgoing: 10000, + beforeConnect: () => { + // 연결 직전마다 최신 토큰을 헤더에 실음 (재연결 시 갱신된 토큰 반영) + // 생성 시점에 넣으면 토큰 만료 시 갱신된 토큰이 반영되지 않음 + client.connectHeaders = { + Authorization: `Bearer ${getAccessToken() ?? ''}`, + }; + }, + }); + + return client; +} diff --git a/src/widgets/header/index.ts b/src/widgets/header/index.ts index 43c1c4b..43d5e72 100644 --- a/src/widgets/header/index.ts +++ b/src/widgets/header/index.ts @@ -1 +1,2 @@ export { Header } from './ui/Header'; +export { useIsLoggedIn } from './model/useIsLoggedIn'; diff --git a/yarn.lock b/yarn.lock index 1cc8978..3fd9f67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -670,6 +670,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz#beacb356412eef5dc0164e9edfee51c563732054" integrity sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg== +"@stomp/stompjs@^7.3.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@stomp/stompjs/-/stompjs-7.3.0.tgz#5655b93e086a0be684291424c5bc8c92949b33ee" + integrity sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ== + "@svgr/babel-plugin-add-jsx-attribute@8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz#4001f5d5dd87fa13303e36ee106e3ff3a7eb8b22"