Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion src/app/layouts/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,6 +25,16 @@ export function AppLayout() {
void refreshSession();
}, []);

if (isFullBleed) {
return (
<div className="flex min-h-screen flex-col bg-neutral-50">
<main className="flex-1">
<Outlet />
</main>
</div>
);
}

return (
<div className="flex min-h-screen flex-col bg-neutral-50">
<Header />
Expand Down
50 changes: 50 additions & 0 deletions src/features/walk-fence/api/fence.ts
Original file line number Diff line number Diff line change
@@ -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<FenceMessageResponse> {
const response = await apiClient.post<FenceMessageResponse>('/fence/range', payload);
return response.data;
}

/** 2. 특정 반려동물의 울타리 활성화 상태 조회 */
export async function getFenceStatus(petId: number): Promise<FenceStatusResponse> {
const response = await apiClient.get<FenceStatusResponse>(`/fence/${petId}/status`);
return response.data;
}

/** 3. 울타리 ON/OFF 변경 */
export async function toggleFence(fenceId: number, payload: ToggleFenceRequest): Promise<FenceMessageResponse> {
const response = await apiClient.patch<FenceMessageResponse>(`/fence/${fenceId}/toggle`, payload);
return response.data;
}

/** 4. 울타리 이름/중심/반경 수정 */
export async function updateFenceRange(
fenceId: number,
payload: UpdateFenceRangeRequest,
): Promise<FenceMessageResponse> {
const response = await apiClient.patch<FenceMessageResponse>(`/fence/${fenceId}/range`, payload);
return response.data;
}

/** 5. 지도에 표시할 단일 울타리 경계 조회 */
export async function getFenceBoundary(fenceId: number): Promise<FenceBoundaryResponse> {
const response = await apiClient.get<FenceBoundaryResponse>(`/fence/${fenceId}/boundary`);
return response.data;
}

/** 6. 접근 가능한 모든 울타리 경계 목록 조회 */
export async function getFenceBoundaries(): Promise<FenceBoundariesResponse> {
const response = await apiClient.get<FenceBoundariesResponse>('/fence/boundaries');
return response.data;
}
29 changes: 29 additions & 0 deletions src/features/walk-fence/index.ts
Original file line number Diff line number Diff line change
@@ -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';
88 changes: 88 additions & 0 deletions src/features/walk-fence/model/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 16 additions & 0 deletions src/features/walk-fence/model/useCreateFence.ts
Original file line number Diff line number Diff line change
@@ -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() });
},
});
}
12 changes: 12 additions & 0 deletions src/features/walk-fence/model/useFenceBoundaries.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
13 changes: 13 additions & 0 deletions src/features/walk-fence/model/useFenceStatus.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
52 changes: 52 additions & 0 deletions src/features/walk-fence/model/useLiveLocation.ts
Original file line number Diff line number Diff line change
@@ -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<LiveLocationPayload | null>(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 };
}
22 changes: 22 additions & 0 deletions src/features/walk-fence/model/useToggleFence.ts
Original file line number Diff line number Diff line change
@@ -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() });
},
});
}
23 changes: 23 additions & 0 deletions src/features/walk-fence/model/useUpdateFenceRange.ts
Original file line number Diff line number Diff line change
@@ -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) });
},
});
}
Loading
Loading