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/app/router.tsx b/src/app/router.tsx
index db4919c..93d19e0 100644
--- a/src/app/router.tsx
+++ b/src/app/router.tsx
@@ -20,6 +20,7 @@ import { NotificationSettingsPage } from '@/pages/my/NotificationSettingsPage';
import { PetRegistrationPage } from '@/pages/my/PetRegistrationPage';
import { PetSpecialNotesPage } from '@/pages/my/PetSpecialNotesPage';
import { PetWeightPage } from '@/pages/my/PetWeightPage';
+import { WithdrawalPage } from '@/pages/my/WithdrawalPage';
import { NotFoundPage } from '@/pages/not-found/NotFoundPage';
import { WalkPage } from '@/pages/walk/WalkPage';
@@ -78,6 +79,14 @@ export const router = createBrowserRouter([
),
},
+ {
+ path: '/my/withdrawal',
+ element: (
+
+
+
+ ),
+ },
{
path: '/my/pets/new',
element: (
diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts
index 750611c..1825c8d 100644
--- a/src/features/auth/api/auth.ts
+++ b/src/features/auth/api/auth.ts
@@ -2,6 +2,7 @@
import { apiClient } from '@/shared/api/axios';
import type {
+ LogoutResponse,
NicknameCheckResponse,
NotificationUpdateResponse,
RegisterProfileRequest,
@@ -35,6 +36,16 @@ export async function socialLogin(provider: SocialProvider, code: string): Promi
return { kind: 'LOGIN', data: response.data as SocialLoginSuccess };
}
+/**
+ * 로그아웃 (POST /auth/logout)
+ * - refreshToken 삭제 + accessToken 블랙리스트 처리
+ * - accessToken은 apiClient 인터셉터가 Authorization 헤더로 자동 첨부
+ */
+export async function logout(refreshToken: string): Promise
{
+ const response = await apiClient.post('/auth/logout', { refreshToken });
+ return response.data;
+}
+
/**
* 추가 정보 입력 → 가입 완료 (PUT /users/me/profile)
* - 202 응답으로 받은 registrationToken을 Authorization 헤더로 전달
diff --git a/src/features/auth/api/users.ts b/src/features/auth/api/users.ts
index b807adc..1afac11 100644
--- a/src/features/auth/api/users.ts
+++ b/src/features/auth/api/users.ts
@@ -1,6 +1,12 @@
import { apiClient } from '@/shared/api/axios';
-import type { UpdateMyProfileRequest, UpdateMyProfileResponse, UserProfile } from '../model/types';
+import type {
+ UpdateMyProfileRequest,
+ UpdateMyProfileResponse,
+ UserProfile,
+ WithdrawUserResponse,
+ WithdrawalEmailResponse,
+} from '../model/types';
/**
* 내 정보 조회 (GET /users/me)
@@ -19,3 +25,24 @@ export async function updateMyProfile(body: UpdateMyProfileRequest): Promise('/users/me', body);
return response.data;
}
+
+/**
+ * 탈퇴 인증 메일 발송 (POST /users/me/withdrawal/email)
+ * - 현재 로그인한 유저 이메일로 인증번호 발송
+ * - 1분 이내 재요청 시 429 응답
+ */
+export async function sendWithdrawalEmail(): Promise {
+ const response = await apiClient.post('/users/me/withdrawal/email');
+ return response.data;
+}
+
+/**
+ * 최종 회원 탈퇴 (DELETE /users/me)
+ * - 메일로 받은 6자리 인증번호(authCode)로 계정 삭제
+ */
+export async function withdrawUser(authCode: string): Promise {
+ const response = await apiClient.delete('/users/me', {
+ data: { authCode },
+ });
+ return response.data;
+}
diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts
index aee7130..c30e743 100644
--- a/src/features/auth/index.ts
+++ b/src/features/auth/index.ts
@@ -19,8 +19,8 @@ export {
export type { AuthErrorPresentation, AuthClientErrorCode, AuthErrorContext } from './lib/authErrorPresentation';
export { AuthLoadingScreen } from './ui/status/AuthLoadingScreen';
export { AuthErrorScreen } from './ui/status/AuthErrorScreen';
-export { socialLogin, registerProfile, checkNicknameAvailability, updateNotificationSetting } from './api/auth';
-export { getMyProfile, updateMyProfile } from './api/users';
+export { socialLogin, logout, registerProfile, checkNicknameAvailability, updateNotificationSetting } from './api/auth';
+export { getMyProfile, updateMyProfile, sendWithdrawalEmail, withdrawUser } from './api/users';
export { useCreatePet } from './model/useCreatePet';
export { useCreatePetInvitationCode } from './model/useCreatePetInvitationCode';
export { useCreatePetSpecialNote } from './model/useCreatePetSpecialNote';
@@ -29,6 +29,9 @@ export { useFamilyApplications } from './model/useFamilyApplications';
export { useFamilyBlockedUsers } from './model/useFamilyBlockedUsers';
export { useFamilyPendingUsers } from './model/useFamilyPendingUsers';
export { useCurrentUser } from './model/useCurrentUser';
+export { useLogout } from './model/useLogout';
+export { useSendWithdrawalEmail } from './model/useSendWithdrawalEmail';
+export { useWithdrawUser } from './model/useWithdrawUser';
export { useApprovePetFamilyRequest } from './model/useApprovePetFamilyRequest';
export { useDeletePetWeight } from './model/useDeletePetWeight';
export { useDeletePetSpecialNote } from './model/useDeletePetSpecialNote';
@@ -50,6 +53,8 @@ export type {
SocialLoginSuccess,
SocialSignupRequired,
SocialLoginResult,
+ LogoutRequest,
+ LogoutResponse,
CreatePetRequest,
CreatePetResponse,
CreatePetSpecialNoteRequest,
@@ -78,6 +83,9 @@ export type {
NotificationUpdateResponse,
UpdateMyProfileRequest,
UpdateMyProfileResponse,
+ WithdrawalEmailResponse,
+ WithdrawUserRequest,
+ WithdrawUserResponse,
PetDetailResponse,
PetFamilyApprovalAction,
PetFamilyApprovalRequest,
@@ -102,8 +110,11 @@ export type {
export { getApiErrorMessage, getErrorBodyMessage } from '@/shared/lib/api/errorMessage';
export {
SOCIAL_LOGIN_STATUS_MESSAGES,
+ LOGOUT_STATUS_MESSAGES,
REGISTER_PROFILE_STATUS_MESSAGES,
NOTIFICATION_SETTING_STATUS_MESSAGES,
PROFILE_UPDATE_STATUS_MESSAGES,
NICKNAME_CHECK_STATUS_MESSAGES,
+ WITHDRAWAL_EMAIL_STATUS_MESSAGES,
+ WITHDRAW_USER_STATUS_MESSAGES,
} from './lib/apiErrorMessages';
diff --git a/src/features/auth/lib/apiErrorMessages.ts b/src/features/auth/lib/apiErrorMessages.ts
index 81dbf44..3db72ed 100644
--- a/src/features/auth/lib/apiErrorMessages.ts
+++ b/src/features/auth/lib/apiErrorMessages.ts
@@ -8,6 +8,14 @@ export const SOCIAL_LOGIN_STATUS_MESSAGES: Partial> = {
500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.',
};
+/** 로그아웃 POST /auth/logout */
+export const LOGOUT_STATUS_MESSAGES: Partial> = {
+ 400: '잘못된 요청이에요. 잠시 후 다시 시도해주세요.',
+ 401: '인증 정보가 유효하지 않아요. 다시 로그인해주세요.',
+ 404: '로그인 정보를 찾을 수 없어요.',
+ 500: '로그아웃에 실패했어요. 잠시 후 다시 시도해주세요.',
+};
+
/** 회원가입 완료 PUT /users/me/profile */
export const REGISTER_PROFILE_STATUS_MESSAGES: Partial> = {
400: '입력 정보를 다시 확인해주세요.',
@@ -31,6 +39,22 @@ export const PROFILE_UPDATE_STATUS_MESSAGES: Partial> = {
500: '회원정보 수정에 실패했어요. 잠시 후 다시 시도해주세요.',
};
+/** 탈퇴 인증 메일 발송 POST /users/me/withdrawal/email */
+export const WITHDRAWAL_EMAIL_STATUS_MESSAGES: Partial> = {
+ 401: '로그인이 필요한 기능이에요. 다시 로그인해주세요.',
+ 404: '사용자를 찾을 수 없어요.',
+ 429: '잠시 후 다시 시도해주세요. (1분 이내 재요청은 불가해요)',
+ 500: '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.',
+};
+
+/** 최종 회원 탈퇴 DELETE /users/me */
+export const WITHDRAW_USER_STATUS_MESSAGES: Partial> = {
+ 400: '인증번호를 다시 확인해주세요.',
+ 401: '인증번호가 올바르지 않거나 만료되었어요. 다시 시도해주세요.',
+ 404: '사용자를 찾을 수 없어요.',
+ 500: '회원 탈퇴에 실패했어요. 잠시 후 다시 시도해주세요.',
+};
+
/** 닉네임 중복 확인 GET /users/nickname/check */
export const NICKNAME_CHECK_STATUS_MESSAGES: Partial> = {
500: '중복 확인에 실패했어요. 잠시 후 다시 시도해주세요.',
diff --git a/src/features/auth/model/types.ts b/src/features/auth/model/types.ts
index d6e58a6..ea0bd47 100644
--- a/src/features/auth/model/types.ts
+++ b/src/features/auth/model/types.ts
@@ -22,6 +22,17 @@ export interface TokenReissueResponse {
accessTokenExpiresIn: number;
}
+// ---- 로그아웃 (POST /auth/logout) ----
+
+export interface LogoutRequest {
+ /** 삭제할 리프레시 토큰 */
+ refreshToken: string;
+}
+
+export interface LogoutResponse {
+ message: string;
+}
+
// ---- 소셜 로그인 (POST /auth/social-login) ----
export interface SocialLoginRequest {
@@ -408,6 +419,23 @@ export interface UpdateMyProfileResponse {
userCreatedAt: string;
}
+// ---- 회원 탈퇴 ----
+
+/** 탈퇴 인증 메일 발송 (POST /users/me/withdrawal/email) */
+export interface WithdrawalEmailResponse {
+ message: string;
+}
+
+/** 최종 회원 탈퇴 (DELETE /users/me) */
+export interface WithdrawUserRequest {
+ /** 메일로 받은 6자리 인증번호 */
+ authCode: string;
+}
+
+export interface WithdrawUserResponse {
+ message: string;
+}
+
export interface UserProfile {
message: string;
userId?: string;
diff --git a/src/features/auth/model/useLogout.ts b/src/features/auth/model/useLogout.ts
new file mode 100644
index 0000000..7a3a85f
--- /dev/null
+++ b/src/features/auth/model/useLogout.ts
@@ -0,0 +1,21 @@
+import { useMutation } from '@tanstack/react-query';
+
+import { logout } from '@/features/auth/api/auth';
+import type { LogoutResponse } from '@/features/auth/model/types';
+import { getRefreshToken } from '@/shared/lib/auth/token';
+
+/**
+ * 로그아웃 (POST /auth/logout)
+ * - refreshToken이 없으면 서버 호출 없이 로컬 세션만 정리하도록 null 반환
+ * - 토큰/캐시 정리·이동은 호출 측에서 처리
+ */
+export function useLogout() {
+ return useMutation({
+ mutationFn: async () => {
+ const refreshToken = getRefreshToken();
+ if (!refreshToken) return null;
+
+ return logout(refreshToken);
+ },
+ });
+}
diff --git a/src/features/auth/model/useSendWithdrawalEmail.ts b/src/features/auth/model/useSendWithdrawalEmail.ts
new file mode 100644
index 0000000..18bd0ef
--- /dev/null
+++ b/src/features/auth/model/useSendWithdrawalEmail.ts
@@ -0,0 +1,11 @@
+import { useMutation } from '@tanstack/react-query';
+
+import { sendWithdrawalEmail } from '@/features/auth/api/users';
+import type { WithdrawalEmailResponse } from '@/features/auth/model/types';
+
+/** 탈퇴 인증 메일 발송 (POST /users/me/withdrawal/email) */
+export function useSendWithdrawalEmail() {
+ return useMutation({
+ mutationFn: () => sendWithdrawalEmail(),
+ });
+}
diff --git a/src/features/auth/model/useWithdrawUser.ts b/src/features/auth/model/useWithdrawUser.ts
new file mode 100644
index 0000000..0251efa
--- /dev/null
+++ b/src/features/auth/model/useWithdrawUser.ts
@@ -0,0 +1,11 @@
+import { useMutation } from '@tanstack/react-query';
+
+import { withdrawUser } from '@/features/auth/api/users';
+import type { WithdrawUserResponse } from '@/features/auth/model/types';
+
+/** 최종 회원 탈퇴 (DELETE /users/me) — 메일로 받은 6자리 인증번호로 계정 삭제 */
+export function useWithdrawUser() {
+ return useMutation({
+ mutationFn: (authCode) => withdrawUser(authCode),
+ });
+}
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/auth/AuthCallbackPage.tsx b/src/pages/auth/AuthCallbackPage.tsx
index 863e908..13fb254 100644
--- a/src/pages/auth/AuthCallbackPage.tsx
+++ b/src/pages/auth/AuthCallbackPage.tsx
@@ -45,6 +45,21 @@ export function AuthCallbackPage() {
const provider = parseProvider(providerParam);
const code = searchParams.get('code');
const state = searchParams.get('state');
+ const error = searchParams.get('error');
+
+ // 모바일 앱(dodo-app)이 인앱 브라우저로 이 페이지를 직접 여는 경우 — 앱이 보낸 state에만
+ // 'app_' 접두사가 붙어있어 구분한다. sessionStorage 기반 state 검증은 앱의 인앱 브라우저
+ // 세션에는 애초에 없으므로(별도 프로세스), 그 검증을 타지 않고 code/state/error를 그대로
+ // dodoapp:// 커스텀 스킴으로 넘겨준다. 실제 토큰 교환/검증은 앱(oauth.ts)이 직접 한다.
+ if (provider && state?.startsWith('app_')) {
+ const deepLinkParams = new URLSearchParams();
+ if (code) deepLinkParams.set('code', code);
+ if (state) deepLinkParams.set('state', state);
+ if (error) deepLinkParams.set('error', error);
+ window.location.replace(`dodoapp://auth/callback/${provider.toLowerCase()}?${deepLinkParams.toString()}`);
+ return;
+ }
+
const storedState = getStoredState();
const returnTo = getStoredReturnTo();
clearStoredState();
diff --git a/src/pages/main/api/home.ts b/src/pages/main/api/home.ts
new file mode 100644
index 0000000..847fbfd
--- /dev/null
+++ b/src/pages/main/api/home.ts
@@ -0,0 +1,8 @@
+import { apiClient } from '@/shared/api/axios';
+
+import type { MainHomeResponse } from '../model/types';
+
+export async function getMainHome(): Promise {
+ const response = await apiClient.get('/main');
+ return response.data;
+}
diff --git a/src/pages/main/model/formatters.ts b/src/pages/main/model/formatters.ts
new file mode 100644
index 0000000..4804174
--- /dev/null
+++ b/src/pages/main/model/formatters.ts
@@ -0,0 +1,106 @@
+const SPECIES_LABEL: Record = {
+ CANINE: '강아지',
+ FELINE: '고양이',
+};
+
+export function getMainPetSpecies(profile: { species?: string; spercies?: string }) {
+ return profile.species ?? profile.spercies ?? '';
+}
+
+export function formatSpeciesLabel(species: string) {
+ return SPECIES_LABEL[species] ?? species;
+}
+
+export function formatWeightLabel(weight: number) {
+ if (!Number.isFinite(weight)) return '-';
+ if (Number.isInteger(weight)) return `${weight} kg`;
+ return `${weight.toFixed(1)} kg`;
+}
+
+export function formatDateLabel(value: string) {
+ if (!value) return '-';
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value.slice(0, 10);
+ }
+
+ return new Intl.DateTimeFormat('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).format(date);
+}
+
+export function getLatestReportByPet(
+ petId: number,
+ reports: T[],
+): T | undefined {
+ let latest: T | undefined;
+ let latestTime = -1;
+
+ for (const report of reports) {
+ if (report.petId !== petId) {
+ continue;
+ }
+
+ const time = new Date(report.checkupDate).getTime();
+ if (!Number.isNaN(time) && time > latestTime) {
+ latestTime = time;
+ latest = report;
+ }
+ }
+
+ return latest;
+}
+
+export function summarizeContent(value: string, maxLength = 140) {
+ const normalized = value.replace(/\s+/g, ' ').trim();
+
+ if (normalized.length <= maxLength) {
+ return normalized;
+ }
+
+ return `${normalized.slice(0, maxLength).trimEnd()}...`;
+}
+
+interface ParsedHealthReportContent {
+ recommendations?: unknown;
+ content?: unknown;
+ summary?: unknown;
+ analysis?: unknown;
+ message?: unknown;
+}
+
+export function extractHealthReportRecommendations(content: string): string[] {
+ if (!content.trim()) return [];
+
+ try {
+ const parsed = JSON.parse(content) as ParsedHealthReportContent | null;
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.recommendations)) {
+ return [];
+ }
+
+ return parsed.recommendations.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
+ } catch {
+ return [];
+ }
+}
+
+export function extractHealthReportDisplayContent(content: string): string | null {
+ if (!content.trim()) return null;
+
+ try {
+ const parsed = JSON.parse(content) as ParsedHealthReportContent | null;
+ if (!parsed || typeof parsed !== 'object') {
+ return null;
+ }
+
+ const candidates = [parsed.content, parsed.summary, parsed.analysis, parsed.message];
+ const text = candidates.find((item): item is string => typeof item === 'string' && item.trim().length > 0);
+
+ return text?.trim() ?? null;
+ } catch {
+ return content;
+ }
+}
diff --git a/src/pages/main/model/quickLinks.tsx b/src/pages/main/model/quickLinks.tsx
index dd32212..0d314cd 100644
--- a/src/pages/main/model/quickLinks.tsx
+++ b/src/pages/main/model/quickLinks.tsx
@@ -12,7 +12,7 @@ export type QuickLinkItem = {
Icon: ComponentType>;
};
-/** 홈 화면 바로가기 메뉴(정적 네비게이션 설정, API 데이터 아님) */
+/** 메인 화면 바로가기 메뉴(정적 내비게이션, API 데이터 아님) */
export const QUICK_LINKS: QuickLinkItem[] = [
{ id: 'pet', label: '나의 반려동물', to: '/my', Icon: PetIcon },
{ id: 'walk', label: '산책 기록', to: '/walk', Icon: WalkIcon },
diff --git a/src/pages/main/model/types.ts b/src/pages/main/model/types.ts
new file mode 100644
index 0000000..c88b9ca
--- /dev/null
+++ b/src/pages/main/model/types.ts
@@ -0,0 +1,35 @@
+export interface MainPetProfile {
+ petId: number;
+ name: string;
+ imageFileUrl: string | null;
+ breed: string;
+ age: number;
+ species?: string;
+ spercies?: string;
+ sex: string;
+ weight: number;
+}
+
+export interface MainHealthReport {
+ petId: number;
+ petName: string;
+ dashboardId: number;
+ healthReportTitle: string;
+ healthReportSummary: string;
+ healthReportContent: string;
+ checkupDate: string;
+}
+
+export interface MainAnnouncement {
+ boardTitle: string;
+ boardContent: string;
+ imageFileUrl: string | null;
+ viewCount: number;
+}
+
+export interface MainHomeResponse {
+ message: string;
+ petProfiles: MainPetProfile[];
+ healthReports: MainHealthReport[];
+ announcement: MainAnnouncement[];
+}
diff --git a/src/pages/main/model/useHomeDashboard.ts b/src/pages/main/model/useHomeDashboard.ts
new file mode 100644
index 0000000..e052759
--- /dev/null
+++ b/src/pages/main/model/useHomeDashboard.ts
@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { getMainHome } from '@/pages/main/api/home';
+import { queryKeys } from '@/shared/lib/react-query/queryKey';
+
+export function useHomeDashboard() {
+ return useQuery({
+ queryKey: queryKeys.main.home(),
+ queryFn: getMainHome,
+ });
+}
diff --git a/src/pages/main/ui/LoggedInHome.tsx b/src/pages/main/ui/LoggedInHome.tsx
index 33d8500..44f997e 100644
--- a/src/pages/main/ui/LoggedInHome.tsx
+++ b/src/pages/main/ui/LoggedInHome.tsx
@@ -1,11 +1,44 @@
+import { useState } from 'react';
+
+import { getApiErrorMessage } from '@/shared/lib/api/errorMessage';
+
+import { getLatestReportByPet } from '../model/formatters';
+import { useHomeDashboard } from '../model/useHomeDashboard';
import { HealthReportSection } from './sections/HealthReportSection';
import { HotTopicSection } from './sections/HotTopicSection';
import { NoticeAndQuickLinksSection } from './sections/NoticeAndQuickLinksSection';
export function LoggedInHome() {
+ const { data, isLoading, isError, error, refetch } = useHomeDashboard();
+ const [selectedPetId, setSelectedPetId] = useState(null);
+
+ const petProfiles = data?.petProfiles ?? [];
+ const healthReports = data?.healthReports ?? [];
+
+ const resolvedSelectedPetId =
+ selectedPetId !== null && petProfiles.some((pet) => pet.petId === selectedPetId)
+ ? selectedPetId
+ : (petProfiles[0]?.petId ?? null);
+ const selectedPet = petProfiles.find((pet) => pet.petId === resolvedSelectedPetId) ?? null;
+ const selectedReport = selectedPet ? getLatestReportByPet(selectedPet.petId, healthReports) : undefined;
+ const errorMessage = isError
+ ? getApiErrorMessage(error, '메인 정보를 불러오지 못했어요. 잠시 후 다시 시도해 주세요.')
+ : null;
+
return (
-
+ {
+ void refetch();
+ }}
+ />
diff --git a/src/pages/main/ui/sections/HealthReportSection.tsx b/src/pages/main/ui/sections/HealthReportSection.tsx
index 7eec547..7bdaa79 100644
--- a/src/pages/main/ui/sections/HealthReportSection.tsx
+++ b/src/pages/main/ui/sections/HealthReportSection.tsx
@@ -1,10 +1,173 @@
import { Link } from 'react-router-dom';
+import { PetImage } from '@/features/family-management/ui/FamilyVisuals';
import DoctorIcon from '@/pages/main/assets/doctor.svg?react';
import FolderIcon from '@/pages/main/assets/report.svg?react';
import PetIcon from '@/pages/main/assets/register-pet.svg?react';
+import type { MainHealthReport, MainPetProfile } from '@/pages/main/model/types';
+import petDefaultCatIllustration from '@/shared/assets/images/pet-default-cat.svg';
+import petDefaultIllustration from '@/shared/assets/images/pet-default.svg';
+import { Skeleton } from '@/shared/ui';
+
+import {
+ extractHealthReportDisplayContent,
+ extractHealthReportRecommendations,
+ formatDateLabel,
+ formatWeightLabel,
+ getMainPetSpecies,
+ summarizeContent,
+} from '../../model/formatters';
+
+interface HealthReportSectionProps {
+ isLoading: boolean;
+ errorMessage: string | null;
+ pets: MainPetProfile[];
+ selectedPetId: number | null;
+ selectedPet: MainPetProfile | null;
+ selectedReport?: MainHealthReport;
+ onSelectPet: (petId: number) => void;
+ onRetry: () => void;
+}
+
+export function HealthReportSection({
+ isLoading,
+ errorMessage,
+ pets,
+ selectedPetId,
+ selectedPet,
+ selectedReport,
+ onSelectPet,
+ onRetry,
+}: HealthReportSectionProps) {
+ if (isLoading) {
+ return (
+
+
+ AI 건강 레포트
+
+
+
+
+
+
+ AI 건강 레포트
+
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+
+
+
+ );
+ }
+
+ if (errorMessage) {
+ return (
+
+
+ AI 건강 레포트
+
+
+
+
{errorMessage}
+
+
+
+ );
+ }
+
+ if (!selectedPet) {
+ return (
+
+
+ AI 건강 레포트
+
+
+
+
+
+
+ AI 건강 레포트
+
+
+
+
+
+
+ 반려동물을 등록하고
+
건강 레포트를 받아보세요!
+
+
+ 산책·식사·활동 기록을 기반으로 한 AI 건강 리포트를 제공합니다.
+
+ 지금 반려동물을 등록하고 관리 기록을 시작해보세요!
+
+
+
+
+
+
+
+
+ 반려동물을 등록하고
+
+ 다양한 서비스를 경험해 보세요!
+
+
+ 반려동물 등록하기
+
+
+
+
+ );
+ }
+
+ const selectedSpecies = getMainPetSpecies(selectedPet);
+ const reportRecommendations = selectedReport
+ ? extractHealthReportRecommendations(selectedReport.healthReportContent)
+ : [];
+ const reportFallbackContent = selectedReport
+ ? extractHealthReportDisplayContent(selectedReport.healthReportContent)
+ : null;
+ const reportPrimaryContent =
+ reportRecommendations[0] ||
+ (reportFallbackContent
+ ? summarizeContent(reportFallbackContent, 96)
+ : '건강 분석 상세 내용이 아직 준비되지 않았어요.');
-export function HealthReportSection() {
return (
@@ -12,41 +175,109 @@ export function HealthReportSection() {
-
+
AI 건강 레포트
-
-
+
+
+
-
- 반려동물을 등록하고
-
건강 레포트를 받아보세요!
+
+ {selectedReport?.healthReportTitle ?? `${selectedPet.name}의 건강 데이터를 분석 중이에요.`}
-
- 산책·식사·활동 기록을 기반으로 한 AI 건강 리포트를 제공합니다.
-
- 지금 반려동물을 등록하고 관리 기록을 시작해보세요!
-
+
+
+ {selectedReport ? (
+ <>
+
{selectedReport.healthReportSummary}
+
{reportPrimaryContent}
+ {reportRecommendations.length > 1 ?
{reportRecommendations[1]}
: null}
+ >
+ ) : (
+
{selectedPet.name}의 첫 건강 레포트를 만들 수 있도록 산책과 건강 기록을 조금 더 쌓아보세요.
+ )}
+
+
+
+
+ {selectedReport ? formatDateLabel(selectedReport.checkupDate) : '레포트 준비 중'}
+
+
-
-
-
- 반려동물을 등록하고
-
- 다양한 서비스를 경험해 보세요!
-
-
- 반려동물 등록하기
-
+
+
+
+
+
+
+
+
+ {selectedPet.name}
+
+
+ 만 {selectedPet.age}세
+
+
+
+
+
+
품종
+
{selectedPet.breed || '-'}
+
+
+
체중
+
+ {formatWeightLabel(selectedPet.weight)}
+
+
+
+
+
+
+
+
+
+ {pets.map((pet) => {
+ const species = getMainPetSpecies(pet);
+ const fallbackImage = species === 'FELINE' ? petDefaultCatIllustration : petDefaultIllustration;
+ const isActive = pet.petId === selectedPetId;
+
+ return (
+
+ );
+ })}
+
+
diff --git a/src/pages/main/ui/sections/NoticeAndQuickLinksSection.tsx b/src/pages/main/ui/sections/NoticeAndQuickLinksSection.tsx
index cef8161..67d0f13 100644
--- a/src/pages/main/ui/sections/NoticeAndQuickLinksSection.tsx
+++ b/src/pages/main/ui/sections/NoticeAndQuickLinksSection.tsx
@@ -18,10 +18,10 @@ export function NoticeAndQuickLinksSection() {
-
공지사항
+
공지사항