From bca723daebe3f15b42739616af625ce40a49519d Mon Sep 17 00:00:00 2001 From: way <127731509+sooloin@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:27:26 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=99=88=20=EB=B0=98=EB=A0=A4=EB=8F=99=EB=AC=BC=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=ED=98=95=20=EA=B1=B4=EA=B0=95=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20UI=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/main/api/home.ts | 8 + src/pages/main/model/formatters.ts | 106 +++++++ src/pages/main/model/quickLinks.tsx | 2 +- src/pages/main/model/types.ts | 35 +++ src/pages/main/model/useHomeDashboard.ts | 11 + src/pages/main/ui/LoggedInHome.tsx | 35 ++- .../main/ui/sections/HealthReportSection.tsx | 281 ++++++++++++++++-- .../sections/NoticeAndQuickLinksSection.tsx | 6 +- src/shared/lib/react-query/queryKey.ts | 3 + 9 files changed, 457 insertions(+), 30 deletions(-) create mode 100644 src/pages/main/api/home.ts create mode 100644 src/pages/main/model/formatters.ts create mode 100644 src/pages/main/model/types.ts create mode 100644 src/pages/main/model/useHomeDashboard.ts 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() {
-

공지사항

+

공지사항

-

바로가기

+

바로가기

{QUICK_LINKS.map(({ id, to, label, Icon }) => ( diff --git a/src/shared/lib/react-query/queryKey.ts b/src/shared/lib/react-query/queryKey.ts index caa3208..1fb92b5 100644 --- a/src/shared/lib/react-query/queryKey.ts +++ b/src/shared/lib/react-query/queryKey.ts @@ -1,4 +1,7 @@ export const queryKeys = { + main: { + home: () => ['main', 'home'] as const, + }, boards: { list: (params?: { page?: number; size?: number }) => ['boards', 'list', params?.page ?? 0, params?.size ?? 12] as const, From ecce6deef80c960b7ef7343977b632768768ba99 Mon Sep 17 00:00:00 2001 From: way <127731509+sooloin@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:21:30 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EC=82=B0=EC=B1=85=20?= =?UTF-8?q?=EC=A7=80=EC=98=A4=ED=8E=9C=EC=8A=A4(=EC=9A=B8=ED=83=80?= =?UTF-8?q?=EB=A6=AC)=20=EB=B0=8F=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=B6=94=EC=A0=81=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/app/layouts/AppLayout.tsx | 16 +- src/features/walk-fence/api/fence.ts | 50 +++++ src/features/walk-fence/index.ts | 29 +++ src/features/walk-fence/model/types.ts | 88 ++++++++ .../walk-fence/model/useCreateFence.ts | 16 ++ .../walk-fence/model/useFenceBoundaries.ts | 12 ++ .../walk-fence/model/useFenceStatus.ts | 13 ++ .../walk-fence/model/useLiveLocation.ts | 52 +++++ .../walk-fence/model/useToggleFence.ts | 22 ++ .../walk-fence/model/useUpdateFenceRange.ts | 23 ++ .../walk-fence/ui/FenceControlPanel.tsx | 167 +++++++++++++++ src/features/walk-fence/ui/WalkMap.tsx | 196 ++++++++++++++++++ src/pages/walk/WalkPage.tsx | 160 +++++++++++++- src/pages/walk/ui/WalkSideRail.tsx | 122 +++++++++++ src/shared/config/env.ts | 6 + src/shared/lib/naver-map/loadNaverMap.ts | 26 +++ src/shared/lib/naver-map/naver.d.ts | 78 +++++++ src/shared/lib/react-query/queryKey.ts | 5 + src/shared/lib/socket/stompClient.ts | 23 ++ src/widgets/header/index.ts | 1 + yarn.lock | 5 + 22 files changed, 1109 insertions(+), 2 deletions(-) create mode 100644 src/features/walk-fence/api/fence.ts create mode 100644 src/features/walk-fence/index.ts create mode 100644 src/features/walk-fence/model/types.ts create mode 100644 src/features/walk-fence/model/useCreateFence.ts create mode 100644 src/features/walk-fence/model/useFenceBoundaries.ts create mode 100644 src/features/walk-fence/model/useFenceStatus.ts create mode 100644 src/features/walk-fence/model/useLiveLocation.ts create mode 100644 src/features/walk-fence/model/useToggleFence.ts create mode 100644 src/features/walk-fence/model/useUpdateFenceRange.ts create mode 100644 src/features/walk-fence/ui/FenceControlPanel.tsx create mode 100644 src/features/walk-fence/ui/WalkMap.tsx create mode 100644 src/pages/walk/ui/WalkSideRail.tsx create mode 100644 src/shared/lib/naver-map/loadNaverMap.ts create mode 100644 src/shared/lib/naver-map/naver.d.ts create mode 100644 src/shared/lib/socket/stompClient.ts 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" From 6509440c7a2645d264a3c8436c164c2de22a1f13 Mon Sep 17 00:00:00 2001 From: way <127731509+sooloin@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:15:31 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=EB=8F=84=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EB=B0=8F=20=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/router.tsx | 9 ++ src/features/auth/api/auth.ts | 11 ++ src/features/auth/api/users.ts | 29 +++- src/features/auth/index.ts | 15 +- src/features/auth/lib/apiErrorMessages.ts | 24 +++ src/features/auth/model/types.ts | 28 ++++ src/features/auth/model/useLogout.ts | 21 +++ .../auth/model/useSendWithdrawalEmail.ts | 11 ++ src/features/auth/model/useWithdrawUser.ts | 11 ++ src/pages/my/WithdrawalPage.tsx | 44 +++++ src/pages/my/model/menu.ts | 20 ++- src/pages/my/ui/AuthCodeInput.tsx | 93 +++++++++++ src/pages/my/ui/LogoutConfirmDialog.tsx | 65 ++++++++ src/pages/my/ui/MyDodoSidebar.tsx | 36 ++++- src/pages/my/ui/MyProfileEditContent.tsx | 18 +++ src/pages/my/ui/WithdrawalCompleteModal.tsx | 40 +++++ src/pages/my/ui/WithdrawalFlow.tsx | 150 ++++++++++++++++++ src/shared/lib/useCooldown.ts | 27 ++++ src/shared/ui/Toast.tsx | 48 ++++++ src/shared/ui/index.ts | 1 + 20 files changed, 682 insertions(+), 19 deletions(-) create mode 100644 src/features/auth/model/useLogout.ts create mode 100644 src/features/auth/model/useSendWithdrawalEmail.ts create mode 100644 src/features/auth/model/useWithdrawUser.ts create mode 100644 src/pages/my/WithdrawalPage.tsx create mode 100644 src/pages/my/ui/AuthCodeInput.tsx create mode 100644 src/pages/my/ui/LogoutConfirmDialog.tsx create mode 100644 src/pages/my/ui/WithdrawalCompleteModal.tsx create mode 100644 src/pages/my/ui/WithdrawalFlow.tsx create mode 100644 src/shared/lib/useCooldown.ts create mode 100644 src/shared/ui/Toast.tsx 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/pages/my/WithdrawalPage.tsx b/src/pages/my/WithdrawalPage.tsx new file mode 100644 index 0000000..5108c62 --- /dev/null +++ b/src/pages/my/WithdrawalPage.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; + +import { WithdrawalCompleteModal } from '@/pages/my/ui/WithdrawalCompleteModal'; +import { WithdrawalFlow } from '@/pages/my/ui/WithdrawalFlow'; + +export function WithdrawalPage() { + const [completed, setCompleted] = useState(false); + + // 토큰 정리는 완료 모달을 닫고 홈으로 이동하는 시점(WithdrawalCompleteModal)으로 미룬다. + // RequireAuth 가드 아래에서 즉시 clearTokens 하면 모달 노출 전에 /auth로 리다이렉트될 수 있다. + const handleCompleted = () => { + setCompleted(true); + }; + + return ( +
+
+ + ← 회원정보 수정으로 돌아가기 + +
+ +
+
+

회원 탈퇴

+

+ 탈퇴를 진행하려면 본인 확인이 필요해요. 가입하신 이메일로 인증번호를 보내드릴게요. +

+ {/*

탈퇴 시 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요.

*/} +
+ +
+ +
+
+ + +
+ ); +} diff --git a/src/pages/my/model/menu.ts b/src/pages/my/model/menu.ts index f77232c..48793ea 100644 --- a/src/pages/my/model/menu.ts +++ b/src/pages/my/model/menu.ts @@ -10,10 +10,14 @@ export type MyDodoMenuKey = | 'notifications' | 'logout'; +/** link: 콘텐츠 패널/페이지로 이동, action: 클릭 시 동작(모달 등) 실행 */ +export type MyDodoMenuType = 'link' | 'action'; + export interface MyDodoMenuItem { key: MyDodoMenuKey; label: string; section: MyDodoMenuSection; + type: MyDodoMenuType; } export interface MyDodoContent { @@ -31,14 +35,14 @@ export const MY_DODO_SECTION_LABELS: Record = { }; export const MY_DODO_MENU_ITEMS: MyDodoMenuItem[] = [ - { key: 'pet-list', label: '반려동물 리스트', section: 'pet' }, - { key: 'device', label: '디바이스 관리', section: 'pet' }, - { key: 'family', label: '가족 관리', section: 'pet' }, - { key: 'walk-history', label: '산책 기록', section: 'pet' }, - { key: 'ai-report', label: 'AI 레포트', section: 'pet' }, - { key: 'profile-edit', label: '회원정보 수정', section: 'account' }, - { key: 'notifications', label: '알림함', section: 'account' }, - { key: 'logout', label: '로그아웃', section: 'account' }, + { key: 'pet-list', label: '반려동물 리스트', section: 'pet', type: 'link' }, + { key: 'device', label: '디바이스 관리', section: 'pet', type: 'link' }, + { key: 'family', label: '가족 관리', section: 'pet', type: 'link' }, + { key: 'walk-history', label: '산책 기록', section: 'pet', type: 'link' }, + { key: 'ai-report', label: 'AI 레포트', section: 'pet', type: 'link' }, + { key: 'profile-edit', label: '회원정보 수정', section: 'account', type: 'link' }, + { key: 'notifications', label: '알림함', section: 'account', type: 'link' }, + { key: 'logout', label: '로그아웃', section: 'account', type: 'action' }, ]; export const MY_DODO_CONTENT_BY_KEY: Record = { diff --git a/src/pages/my/ui/AuthCodeInput.tsx b/src/pages/my/ui/AuthCodeInput.tsx new file mode 100644 index 0000000..97f06b4 --- /dev/null +++ b/src/pages/my/ui/AuthCodeInput.tsx @@ -0,0 +1,93 @@ +import { useRef, type ClipboardEvent, type KeyboardEvent } from 'react'; + +interface AuthCodeInputProps { + length: number; + value: string; + onChange: (value: string) => void; + disabled?: boolean; +} + +export function AuthCodeInput({ length, value, onChange, disabled = false }: AuthCodeInputProps) { + const inputsRef = useRef>([]); + + const focusInput = (index: number) => { + inputsRef.current[Math.max(0, Math.min(index, length - 1))]?.focus(); + }; + + const handleChange = (index: number, raw: string) => { + const chars = raw.replace(/\D/g, '').split(''); + if (chars.length === 0) { + const next = value.split(''); + next[index] = ''; + onChange(next.join('')); + return; + } + + const next = value.padEnd(length, ' ').split(''); + let cursor = index; + for (const ch of chars) { + if (cursor >= length) break; + next[cursor] = ch; + cursor += 1; + } + + onChange(next.join('').replace(/ /g, '').slice(0, length)); + focusInput(cursor); + }; + + const handleKeyDown = (index: number, event: KeyboardEvent) => { + if (event.key === 'Backspace') { + const next = value.split(''); + if (value[index]) { + next[index] = ''; + onChange(next.join('')); + } else if (index > 0) { + next[index - 1] = ''; + onChange(next.join('')); + focusInput(index - 1); + } + } else if (event.key === 'ArrowLeft') { + focusInput(index - 1); + } else if (event.key === 'ArrowRight') { + focusInput(index + 1); + } + }; + + const handlePaste = (event: ClipboardEvent) => { + event.preventDefault(); + const pasted = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, length); + if (!pasted) return; + + onChange(pasted); + focusInput(pasted.length); + }; + + return ( +
+ {Array.from({ length }).map((_, index) => ( + { + inputsRef.current[index] = el; + }} + type="text" + inputMode="numeric" + autoComplete={index === 0 ? 'one-time-code' : 'off'} + maxLength={1} + value={value[index] ?? ''} + disabled={disabled} + onChange={(event) => handleChange(index, event.target.value)} + onKeyDown={(event) => handleKeyDown(index, event)} + onFocus={() => { + // 빈 슬롯보다 뒤쪽 칸을 클릭하면 첫 번째 빈 슬롯으로 포커스를 당겨 입력 순서를 맞춘다. + const firstEmptyIndex = value.length; + if (index > firstEmptyIndex) { + focusInput(firstEmptyIndex); + } + }} + className="h-20 w-full rounded-xl border border-neutral-200 bg-white text-center text-2xl font-semibold text-neutral-900 outline-none transition-colors focus:border-brand disabled:bg-neutral-50 disabled:opacity-60" + /> + ))} +
+ ); +} diff --git a/src/pages/my/ui/LogoutConfirmDialog.tsx b/src/pages/my/ui/LogoutConfirmDialog.tsx new file mode 100644 index 0000000..202cee1 --- /dev/null +++ b/src/pages/my/ui/LogoutConfirmDialog.tsx @@ -0,0 +1,65 @@ +import { useNavigate } from 'react-router-dom'; + +import { useLogout } from '@/features/auth'; +import { clearTokens } from '@/shared/lib/auth/token'; +import { Modal } from '@/shared/ui'; + +interface LogoutConfirmDialogProps { + open: boolean; + onClose: () => void; +} + +export function LogoutConfirmDialog({ open, onClose }: LogoutConfirmDialogProps) { + const navigate = useNavigate(); + const { mutateAsync, isPending } = useLogout(); + + const handleClose = () => { + if (isPending) return; + onClose(); + }; + + const handleConfirm = async () => { + try { + await mutateAsync(); + } catch (error) { + // 서버 로그아웃이 실패해도 로컬 세션은 반드시 정리해 사용자가 갇히지 않도록 한다. + console.error('[auth/logout] 서버 로그아웃 실패', error); + } finally { + // 인증 페이지(마이도도)에서 먼저 빠져나간 뒤 토큰을 정리해야 + // 잔여 인증 쿼리의 재요청 → 401 → 세션 만료 리다이렉트를 피할 수 있다. + navigate('/', { replace: true }); + clearTokens(); + } + }; + + return ( + +
+

LOGOUT

+

로그아웃 하시겠습니까?

+

+ 로그아웃하면 현재 기기에서 로그인 정보가 정리돼요. 다시 이용하려면 로그인이 필요해요. +

+ +
+ + +
+
+
+ ); +} diff --git a/src/pages/my/ui/MyDodoSidebar.tsx b/src/pages/my/ui/MyDodoSidebar.tsx index 9f967b7..3f35959 100644 --- a/src/pages/my/ui/MyDodoSidebar.tsx +++ b/src/pages/my/ui/MyDodoSidebar.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; import { @@ -7,6 +8,7 @@ import { type MyDodoMenuKey, type MyDodoMenuSection, } from '@/pages/my/model/menu'; +import { LogoutConfirmDialog } from '@/pages/my/ui/LogoutConfirmDialog'; interface MyDodoSidebarProps { activeKey: MyDodoMenuKey; @@ -21,30 +23,48 @@ function menuItemClass(active: boolean) { ].join(' '); } -function SidebarSection({ section, activeKey }: { section: MyDodoMenuSection; activeKey: MyDodoMenuKey }) { +function SidebarSection({ + section, + activeKey, + onLogout, +}: { + section: MyDodoMenuSection; + activeKey: MyDodoMenuKey; + onLogout: () => void; +}) { const items = MY_DODO_MENU_ITEMS.filter((item) => item.section === section); return (

{MY_DODO_SECTION_LABELS[section]}

- {items.map((item) => ( - - {item.label} - - ))} + {items.map((item) => + item.type === 'action' ? ( + + ) : ( + + {item.label} + + ), + )}
); } export function MyDodoSidebar({ activeKey }: MyDodoSidebarProps) { + const [logoutOpen, setLogoutOpen] = useState(false); + return (
- - + setLogoutOpen(true)} /> + setLogoutOpen(true)} />
+ + setLogoutOpen(false)} />
); } diff --git a/src/pages/my/ui/MyProfileEditContent.tsx b/src/pages/my/ui/MyProfileEditContent.tsx index 36bc020..d818c96 100644 --- a/src/pages/my/ui/MyProfileEditContent.tsx +++ b/src/pages/my/ui/MyProfileEditContent.tsx @@ -1,4 +1,5 @@ import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; import profileDefaultIllustration from '@/features/auth/assets/profile-default.svg'; import { PROFILE_UPDATE_STATUS_MESSAGES, getApiErrorMessage, updateMyProfile, type UserProfile } from '@/features/auth'; @@ -320,6 +321,23 @@ export function MyProfileEditContent({ user, isLoading = false }: MyProfileEditC
+
+
+
+

회원 탈퇴

+

+ 탈퇴하면 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요. +

+
+ + 회원 탈퇴 + +
+
+ { + navigate('/', { replace: true }); + clearTokens(); + }; + + return ( + +
+ +

회원 탈퇴 완료

+

+ 그동안 DoDo를 이용해 주셔서 감사합니다. +
더 좋은 모습으로 다시 만날 수 있기를 바라요. +

+ + +
+
+ ); +} diff --git a/src/pages/my/ui/WithdrawalFlow.tsx b/src/pages/my/ui/WithdrawalFlow.tsx new file mode 100644 index 0000000..c850624 --- /dev/null +++ b/src/pages/my/ui/WithdrawalFlow.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; + +import { + WITHDRAWAL_EMAIL_STATUS_MESSAGES, + WITHDRAW_USER_STATUS_MESSAGES, + getApiErrorMessage, + useSendWithdrawalEmail, + useWithdrawUser, +} from '@/features/auth'; +import { getApiErrorStatus } from '@/shared/lib/api/errorMessage'; +import { useCooldown } from '@/shared/lib/useCooldown'; +import { Toast } from '@/shared/ui'; +import { AuthCodeInput } from '@/pages/my/ui/AuthCodeInput'; + +const AUTH_CODE_LENGTH = 6; +const RESEND_COOLDOWN_SECONDS = 60; +/** 인증번호 불일치로 간주하는 상태 코드 */ +const INVALID_CODE_STATUSES = new Set([400, 401]); + +interface WithdrawalFlowProps { + /** 최종 탈퇴 성공 시 호출 (토큰 정리·완료 화면 전환은 상위에서 처리) */ + onCompleted: () => void; +} + +interface ToastState { + message: string; + tone: 'success' | 'error'; +} + +export function WithdrawalFlow({ onCompleted }: WithdrawalFlowProps) { + const [emailSent, setEmailSent] = useState(false); + const [authCode, setAuthCode] = useState(''); + const [withdrawError, setWithdrawError] = useState(''); + const [toast, setToast] = useState(null); + const { seconds: cooldown, start: startCooldown } = useCooldown(); + + const { mutateAsync: sendEmail, isPending: isSending } = useSendWithdrawalEmail(); + const { mutateAsync: withdraw, isPending: isWithdrawing } = useWithdrawUser(); + + const handleSendEmail = async () => { + if (isSending || cooldown > 0) return; + + try { + await sendEmail(); + setEmailSent(true); + startCooldown(RESEND_COOLDOWN_SECONDS); + setToast({ message: '인증번호를 메일로 보냈어요. 메일함을 확인해주세요.', tone: 'success' }); + } catch (error) { + setToast({ + message: getApiErrorMessage( + error, + '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.', + WITHDRAWAL_EMAIL_STATUS_MESSAGES, + ), + tone: 'error', + }); + } + }; + + const handleCodeChange = (value: string) => { + setAuthCode(value); + setWithdrawError(''); + }; + + const handleWithdraw = async () => { + if (isWithdrawing) return; + + if (authCode.length !== AUTH_CODE_LENGTH) { + setWithdrawError('6자리 인증번호를 입력해주세요.'); + return; + } + + setWithdrawError(''); + + try { + await withdraw(authCode); + onCompleted(); + } catch (error) { + const status = getApiErrorStatus(error); + if (status !== null && INVALID_CODE_STATUSES.has(status)) { + setWithdrawError('인증번호가 틀렸습니다. 다시 확인해주세요.'); + return; + } + + setWithdrawError( + getApiErrorMessage(error, '회원 탈퇴에 실패했어요. 잠시 후 다시 시도해주세요.', WITHDRAW_USER_STATUS_MESSAGES), + ); + } + }; + + return ( + <> + setToast(null)} + /> + + {!emailSent ? ( + + ) : ( +
+
+

인증번호

+

메일로 받은 6자리 숫자를 입력해주세요.

+
+ +
+
+ +
+ 인증번호를 받지 못하셨나요? + +
+ + {withdrawError ?

{withdrawError}

: null} + + +
+ )} + + ); +} diff --git a/src/shared/lib/useCooldown.ts b/src/shared/lib/useCooldown.ts new file mode 100644 index 0000000..bc9b1fb --- /dev/null +++ b/src/shared/lib/useCooldown.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +interface UseCooldownResult { + /** 남은 초 (0이면 쿨다운 종료) */ + seconds: number; + /** 지정한 초만큼 쿨다운 시작 */ + start: (durationSeconds: number) => void; +} + +/** 초 단위 카운트다운 쿨다운 (재발송 제한 등) */ +export function useCooldown(): UseCooldownResult { + const [seconds, setSeconds] = useState(0); + const isActive = seconds > 0; + + // isActive(쿨다운 진행 여부)가 바뀔 때만 타이머를 재설정해 매초 재생성되지 않도록 한다. + useEffect(() => { + if (!isActive) return; + + const timer = setInterval(() => { + setSeconds((prev) => (prev <= 1 ? 0 : prev - 1)); + }, 1000); + + return () => clearInterval(timer); + }, [isActive]); + + return { seconds, start: setSeconds }; +} diff --git a/src/shared/ui/Toast.tsx b/src/shared/ui/Toast.tsx new file mode 100644 index 0000000..990bbce --- /dev/null +++ b/src/shared/ui/Toast.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +type ToastTone = 'default' | 'success' | 'error'; + +interface ToastProps { + open: boolean; + message: string; + onClose: () => void; + tone?: ToastTone; + /** 자동 사라짐 시간(ms) */ + duration?: number; +} + +const TONE_CLASS: Record = { + default: 'bg-neutral-950 text-white', + success: 'bg-neutral-950 text-white', + error: 'bg-red-500 text-white', +}; + +export function Toast({ open, message, onClose, tone = 'default', duration = 3000 }: ToastProps) { + const onCloseRef = useRef(onClose); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + if (!open) return; + + const timer = setTimeout(() => onCloseRef.current(), duration); + return () => clearTimeout(timer); + }, [open, duration, message]); + + if (!open) return null; + + return createPortal( +
+
+ {message} +
+
, + document.body, + ); +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 97d5cb7..abc7fbb 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -2,3 +2,4 @@ export { CloseButton } from './CloseButton'; export { LoadingSpinner } from './LoadingSpinner'; export { Modal } from './Modal'; export { Skeleton } from './Skeleton'; +export { Toast } from './Toast'; From 51f51d8add50bc74ea90e58b44f82a08f349fd33 Mon Sep 17 00:00:00 2001 From: sooloin Date: Fri, 28 Aug 2026 10:19:24 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9E=20Fix:=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=EC=95=B1=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=BD=9C=EB=B0=B1=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 안드로이드가 OAuth 서버(Google/Naver)의 리다이렉트로 커스텀 URI 스킴을 직접 받는 걸 차단해서, 앱에서 로그인 시도 시 콜백을 받지 못하던 문제 - AuthCallbackPage에서 state 값이 'app_' 접두사로 시작하면 모바일 앱 (dodo-app)에서 시작된 요청으로 판단해 code/state/error를 dodoapp:// 커스텀 스킴으로 그대로 재전달하도록 분기 추가 - 앱의 인앱 브라우저는 별도 세션이라 기존 sessionStorage 기반 state 검증 로직을 타지 않고 바로 리다이렉트 - 웹 자체 로그인 흐름은 영향 없음 — 웹의 state는 crypto.randomUUID()라 'p' 문자가 나올 수 없어 'app_' 분기와 절대 겹치지 않음 --- src/pages/auth/AuthCallbackPage.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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();