From cd4589e761a4c84fde494d6b724d40511310e97c Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 09:15:47 +0900 Subject: [PATCH 1/4] =?UTF-8?q?:sparkles:=20Feat:=20=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=98=EB=A0=A4=EB=8F=99?= =?UTF-8?q?=EB=AC=BC=20=EC=84=A0=ED=83=9D=20=EA=B8=B0=EB=B0=98=20=EA=B1=B4?= =?UTF-8?q?=EA=B0=95=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=201=EC=B0=A8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메인페이지 정보 조회 API 타입 및 react-query 훅 추가 - 선택된 반려동물 기준 최신 AI 건강 레포트 노출 로직 구현 - 공지사항/바로가기 섹션을 데이터 기반 UI로 재구성 - 반려동물 없음, 레포트 없음, 로딩, 에러 상태 UI 추가 `#60` --- src/pages/main/api/home.ts | 8 + src/pages/main/model/formatters.ts | 58 ++++ 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 | 46 +++- .../main/ui/sections/HealthReportSection.tsx | 256 +++++++++++++++--- .../sections/NoticeAndQuickLinksSection.tsx | 114 +++++--- src/shared/lib/react-query/queryKey.ts | 3 + 9 files changed, 461 insertions(+), 72 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..3ee42d9 --- /dev/null +++ b/src/pages/main/model/formatters.ts @@ -0,0 +1,58 @@ +const SPECIES_LABEL: Record = { + CANINE: '강아지', + FELINE: '고양이', +}; + +const SEX_LABEL: Record = { + MALE: '남아', + FEMALE: '여아', + NEUTER: '중성화', +}; + +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 formatSexLabel(sex: string) { + return SEX_LABEL[sex] ?? sex; +} + +export function formatWeightLabel(weight: number) { + if (!Number.isFinite(weight)) return '-'; + 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[]) { + return [...reports] + .filter((report) => report.petId === petId) + .sort((left, right) => new Date(right.checkupDate).getTime() - new Date(left.checkupDate).getTime())[0]; +} + +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()}...`; +} 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..dcde199 100644 --- a/src/pages/main/ui/LoggedInHome.tsx +++ b/src/pages/main/ui/LoggedInHome.tsx @@ -1,13 +1,49 @@ +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, isFetching } = useHomeDashboard(); + const [selectedPetId, setSelectedPetId] = useState(null); + + const petProfiles = data?.petProfiles ?? []; + const healthReports = data?.healthReports ?? []; + const announcements = data?.announcement ?? []; + + 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..3550021 100644 --- a/src/pages/main/ui/sections/HealthReportSection.tsx +++ b/src/pages/main/ui/sections/HealthReportSection.tsx @@ -2,51 +2,245 @@ import { Link } from 'react-router-dom'; 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 RegisterPetIcon from '@/pages/main/assets/register-pet.svg?react'; +import type { MainHealthReport, MainPetProfile } from '@/pages/main/model/types'; +import { PetImage } from '@/features/family-management/ui/FamilyVisuals'; +import { Skeleton } from '@/shared/ui'; -export function HealthReportSection() { +import { + formatDateLabel, + formatSexLabel, + formatSpeciesLabel, + 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) { return (

AI 건강 레포트

-
-
+
+
- AI 건강 레포트 + AI 건강 레포트
-
- -
-

- 반려동물을 등록하고 -
건강 레포트를 받아보세요! -

-

- 산책·식사·활동 기록을 기반으로 한 AI 건강 리포트를 제공합니다. -
- 지금 반려동물을 등록하고 관리 기록을 시작해보세요! -

+ {isLoading ? ( +
+ +
+ + + + +
-
+ ) : errorMessage ? ( +
+

{errorMessage}

+ +
+ ) : !selectedPet ? ( +
+ +
+ + WELCOME TO DODO + +

+ 반려동물을 등록하고 +
+ 맞춤 건강 레포트를 받아보세요 +

+

+ 반려동물 프로필과 기록이 쌓이면 메인에서 바로 확인할 수 있는 AI 건강 레포트를 제공해 드려요. +

+ + 반려동물 등록하러 가기 + +
+
+ ) : ( +
+
+ +
+ +
+
+ + {selectedPet.name} 맞춤 분석 + + {selectedReport ? ( + {formatDateLabel(selectedReport.checkupDate)} 기준 + ) : null} +
+ + {selectedReport ? ( + <> +

+ "{selectedReport.healthReportTitle}" +

+

+ {selectedReport.healthReportSummary} +

+

+ {summarizeContent(selectedReport.healthReportContent)} +

+ + ) : ( + <> +

+ {selectedPet.name}의 첫 건강 레포트를 준비해 볼까요? +

+

+ 아직 등록된 AI 건강 레포트가 없어요. 산책, 체중, 특이사항 기록이 쌓이면 더 정교한 분석을 확인할 수 + 있어요. +

+ + )} + +
+ + {selectedReport ? '반려동물 상세 보기' : '기록 관리하러 가기'} + + + 산책 기록 보러가기 + +
+
+
+ )}
-
- -

- 반려동물을 등록하고 -
- 다양한 서비스를 경험해 보세요! -

- - 반려동물 등록하기 - +
+
+
+
+

SELECT PET

+

반려동물 프로필

+
+ {pets.length > 0 ? ( + + 총 {pets.length}마리 + + ) : null} +
+ + {isLoading ? ( +
+ +
+ + + +
+
+ ) : selectedPet ? ( +
+ +
+

{selectedPet.name}

+
+

+ 만 {selectedPet.age}세 · {formatSpeciesLabel(getMainPetSpecies(selectedPet))} +

+

+ {selectedPet.breed} · {formatSexLabel(selectedPet.sex)} +

+

{formatWeightLabel(selectedPet.weight)}

+
+
+
+ ) : ( +

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

+ )} +
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ) : pets.length > 0 ? ( +
+ {pets.map((pet) => { + const species = getMainPetSpecies(pet); + 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..dd6b837 100644 --- a/src/pages/main/ui/sections/NoticeAndQuickLinksSection.tsx +++ b/src/pages/main/ui/sections/NoticeAndQuickLinksSection.tsx @@ -1,61 +1,105 @@ import { Link } from 'react-router-dom'; -import { NOTICES, type NoticeTag } from '@/pages/main/model/homeMock'; +import type { MainAnnouncement } from '@/pages/main/model/types'; import { QUICK_LINKS } from '@/pages/main/model/quickLinks'; +import { Skeleton } from '@/shared/ui'; -const TAG_STYLES: Record = { - 안내: 'bg-[#E8F5E9] text-[#2E7D32]', - 긴급: 'bg-[#FFEBEE] text-[#C62828]', -}; +import { summarizeContent } from '../../model/formatters'; -export function NoticeAndQuickLinksSection() { +interface NoticeAndQuickLinksSectionProps { + isLoading: boolean; + errorMessage: string | null; + announcements: MainAnnouncement[]; +} + +export function NoticeAndQuickLinksSection({ + isLoading, + errorMessage, + announcements, +}: NoticeAndQuickLinksSectionProps) { return (

공지사항 및 바로가기

-
-
-
-

공지사항

- + 더 보기 +
-
    - {NOTICES.map((notice) => ( -
  • -
+ ) : errorMessage ? ( +
+

{errorMessage}

+
+ ) : announcements.length > 0 ? ( +
    + {announcements.slice(0, 4).map((announcement, index) => ( +
  • + - {notice.tag} - - {notice.title} - -
  • - ))} -
+
+ + 공지 + + 조회 {announcement.viewCount} +
+

+ {announcement.boardTitle} +

+

+ {summarizeContent(announcement.boardContent, 88)} +

+ + + ))} + + ) : ( +
+

+ 등록된 공지사항이 없어요. 새로운 소식이 올라오면 이곳에서 바로 확인할 수 있어요. +

+
+ )} -
-

바로가기

+
+
+

SHORTCUT

+

바로가기

+
-
+
{QUICK_LINKS.map(({ id, to, label, Icon }) => ( - - {label} + + {label} ))}
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 cc3c52bd8bc2938dfc9f3545ee4dfb34ce662c7b Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 12:56:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?:sparkles:=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=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메인 홈 API 연동 및 반려동물 선택 상태 추가 - 반려동물 미등록 시 기존 홈 UI 유지 - 반려동물 등록 시 선택형 프로필 카드와 건강 레포트 UI 노출 - healthReportContent JSON 파싱 후 recommendations 우선 노출 - 레포트 영역에 title, summary, content 순서 적용 - 우측 프로필 카드 정보 구조 및 썸네일 크기/정렬 개선 `#60` --- src/pages/main/model/formatters.ts | 32 +- src/pages/main/ui/LoggedInHome.tsx | 11 +- .../main/ui/sections/HealthReportSection.tsx | 378 ++++++++++-------- .../sections/NoticeAndQuickLinksSection.tsx | 114 ++---- 4 files changed, 265 insertions(+), 270 deletions(-) diff --git a/src/pages/main/model/formatters.ts b/src/pages/main/model/formatters.ts index 3ee42d9..2bf06d1 100644 --- a/src/pages/main/model/formatters.ts +++ b/src/pages/main/model/formatters.ts @@ -3,12 +3,6 @@ const SPECIES_LABEL: Record = { FELINE: '고양이', }; -const SEX_LABEL: Record = { - MALE: '남아', - FEMALE: '여아', - NEUTER: '중성화', -}; - export function getMainPetSpecies(profile: { species?: string; spercies?: string }) { return profile.species ?? profile.spercies ?? ''; } @@ -17,13 +11,10 @@ export function formatSpeciesLabel(species: string) { return SPECIES_LABEL[species] ?? species; } -export function formatSexLabel(sex: string) { - return SEX_LABEL[sex] ?? sex; -} - export function formatWeightLabel(weight: number) { if (!Number.isFinite(weight)) return '-'; - return `${weight.toFixed(1)}kg`; + if (Number.isInteger(weight)) return `${weight} kg`; + return `${weight.toFixed(1)} kg`; } export function formatDateLabel(value: string) { @@ -56,3 +47,22 @@ export function summarizeContent(value: string, maxLength = 140) { return `${normalized.slice(0, maxLength).trimEnd()}...`; } + +interface ParsedHealthReportContent { + recommendations?: unknown; +} + +export function extractHealthReportRecommendations(content: string): string[] { + if (!content.trim()) return []; + + try { + const parsed = JSON.parse(content) as ParsedHealthReportContent; + if (!Array.isArray(parsed.recommendations)) { + return []; + } + + return parsed.recommendations.filter((item): item is string => typeof item === 'string' && item.trim().length > 0); + } catch { + return []; + } +} diff --git a/src/pages/main/ui/LoggedInHome.tsx b/src/pages/main/ui/LoggedInHome.tsx index dcde199..bd306fb 100644 --- a/src/pages/main/ui/LoggedInHome.tsx +++ b/src/pages/main/ui/LoggedInHome.tsx @@ -5,6 +5,7 @@ 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() { @@ -13,7 +14,6 @@ export function LoggedInHome() { const petProfiles = data?.petProfiles ?? []; const healthReports = data?.healthReports ?? []; - const announcements = data?.announcement ?? []; const resolvedSelectedPetId = selectedPetId !== null && petProfiles.some((pet) => pet.petId === selectedPetId) @@ -26,7 +26,7 @@ export function LoggedInHome() { : null; return ( -
+
- + +
); } diff --git a/src/pages/main/ui/sections/HealthReportSection.tsx b/src/pages/main/ui/sections/HealthReportSection.tsx index 3550021..73d96d4 100644 --- a/src/pages/main/ui/sections/HealthReportSection.tsx +++ b/src/pages/main/ui/sections/HealthReportSection.tsx @@ -1,16 +1,17 @@ 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 RegisterPetIcon from '@/pages/main/assets/register-pet.svg?react'; +import PetIcon from '@/pages/main/assets/register-pet.svg?react'; import type { MainHealthReport, MainPetProfile } from '@/pages/main/model/types'; -import { PetImage } from '@/features/family-management/ui/FamilyVisuals'; +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 { + extractHealthReportRecommendations, formatDateLabel, - formatSexLabel, - formatSpeciesLabel, formatWeightLabel, getMainPetSpecies, summarizeContent, @@ -37,209 +38,240 @@ export function HealthReportSection({ onSelectPet, onRetry, }: HealthReportSectionProps) { - return ( -
-

- AI 건강 레포트 -

+ if (isLoading) { + return ( +
+

+ AI 건강 레포트 +

-
-
-
- - AI 건강 레포트 -
+
+
+
+ + AI 건강 레포트 +
- {isLoading ? ( -
- -
- - +
+ +
+ + +
+
+
+ +
+
+ +
+ + + +
- ) : errorMessage ? ( -
-

{errorMessage}

- +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))}
- ) : !selectedPet ? ( -
- +
+
+
+ ); + } + + if (errorMessage) { + return ( +
+

+ AI 건강 레포트 +

+ +
+

{errorMessage}

+ +
+
+ ); + } + + if (!selectedPet) { + return ( +
+

+ AI 건강 레포트 +

+ +
+
+
+ + AI 건강 레포트 +
+ +
+
- - WELCOME TO DODO - -

+

반려동물을 등록하고 -
- 맞춤 건강 레포트를 받아보세요 +
건강 레포트를 받아보세요!

-

- 반려동물 프로필과 기록이 쌓이면 메인에서 바로 확인할 수 있는 AI 건강 레포트를 제공해 드려요. +

+ 산책·식사·활동 기록을 기반으로 한 AI 건강 리포트를 제공합니다. +
+ 지금 반려동물을 등록하고 관리 기록을 시작해보세요!

- - 반려동물 등록하러 가기 -
- ) : ( -
-
- -
+
-
-
- - {selectedPet.name} 맞춤 분석 - - {selectedReport ? ( - {formatDateLabel(selectedReport.checkupDate)} 기준 - ) : null} -
+
+ +

+ 반려동물을 등록하고 +
+ 다양한 서비스를 경험해 보세요! +

+ + 반려동물 등록하기 + +
+
+
+ ); + } + const selectedSpecies = getMainPetSpecies(selectedPet); + const reportRecommendations = selectedReport + ? extractHealthReportRecommendations(selectedReport.healthReportContent) + : []; + const reportPrimaryContent = + reportRecommendations[0] || + selectedReport?.healthReportSummary || + (selectedReport ? summarizeContent(selectedReport.healthReportContent, 96) : ''); + + return ( +
+

+ AI 건강 레포트 +

+ +
+
+
+ + AI 건강 레포트 +
+ +
+ + +
+

+ {selectedReport?.healthReportTitle ?? `${selectedPet.name}의 건강 데이터를 분석 중이에요.`} +

+ +
{selectedReport ? ( <> -

- "{selectedReport.healthReportTitle}" -

-

- {selectedReport.healthReportSummary} -

-

- {summarizeContent(selectedReport.healthReportContent)} -

+

{selectedReport.healthReportSummary}

+

{reportPrimaryContent}

+ {reportRecommendations.length > 1 ?

{reportRecommendations[1]}

: null} ) : ( - <> -

- {selectedPet.name}의 첫 건강 레포트를 준비해 볼까요? -

-

- 아직 등록된 AI 건강 레포트가 없어요. 산책, 체중, 특이사항 기록이 쌓이면 더 정교한 분석을 확인할 수 - 있어요. -

- +

{selectedPet.name}의 첫 건강 레포트를 만들 수 있도록 산책과 건강 기록을 조금 더 쌓아보세요.

)} +
-
- - {selectedReport ? '반려동물 상세 보기' : '기록 관리하러 가기'} - - - 산책 기록 보러가기 - -
+
+ + {selectedReport ? formatDateLabel(selectedReport.checkupDate) : '레포트 준비 중'} +
- )} +
-
-
-
-
-

SELECT PET

-

반려동물 프로필

+
+
+
+
+
- {pets.length > 0 ? ( - - 총 {pets.length}마리 - - ) : null} -
- {isLoading ? ( -
- -
- - - +
+
+

+ {selectedPet.name} +

+ + 만 {selectedPet.age}세 +
-
- ) : selectedPet ? ( -
- -
-

{selectedPet.name}

-
-

- 만 {selectedPet.age}세 · {formatSpeciesLabel(getMainPetSpecies(selectedPet))} -

-

- {selectedPet.breed} · {formatSexLabel(selectedPet.sex)} + +

+
+

품종

+

{selectedPet.breed || '-'}

+
+
+

체중

+

+ {formatWeightLabel(selectedPet.weight)}

-

{formatWeightLabel(selectedPet.weight)}

- ) : ( -

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

- )} +
-
- {isLoading ? ( -
- {Array.from({ length: 3 }).map((_, index) => ( - - ))} -
- ) : pets.length > 0 ? ( -
- {pets.map((pet) => { - const species = getMainPetSpecies(pet); - const isActive = pet.petId === selectedPetId; - - return ( - - ); - })} -
- ) : ( - - 첫 반려동물 등록하기 - - )} +
+
+ {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 dd6b837..67d0f13 100644 --- a/src/pages/main/ui/sections/NoticeAndQuickLinksSection.tsx +++ b/src/pages/main/ui/sections/NoticeAndQuickLinksSection.tsx @@ -1,105 +1,61 @@ import { Link } from 'react-router-dom'; -import type { MainAnnouncement } from '@/pages/main/model/types'; +import { NOTICES, type NoticeTag } from '@/pages/main/model/homeMock'; import { QUICK_LINKS } from '@/pages/main/model/quickLinks'; -import { Skeleton } from '@/shared/ui'; -import { summarizeContent } from '../../model/formatters'; +const TAG_STYLES: Record = { + 안내: 'bg-[#E8F5E9] text-[#2E7D32]', + 긴급: 'bg-[#FFEBEE] text-[#C62828]', +}; -interface NoticeAndQuickLinksSectionProps { - isLoading: boolean; - errorMessage: string | null; - announcements: MainAnnouncement[]; -} - -export function NoticeAndQuickLinksSection({ - isLoading, - errorMessage, - announcements, -}: NoticeAndQuickLinksSectionProps) { +export function NoticeAndQuickLinksSection() { return (

공지사항 및 바로가기

-
-
-
-
-

NOTICE

-

공지사항

-
- +
+
+

공지사항

+
- {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, index) => ( -
- - - -
- ))} -
- ) : errorMessage ? ( -
-

{errorMessage}

-
- ) : announcements.length > 0 ? ( -
    - {announcements.slice(0, 4).map((announcement, index) => ( -
  • - + {NOTICES.map((notice) => ( +
  • +
  • - ))} -
- ) : ( -
-

- 등록된 공지사항이 없어요. 새로운 소식이 올라오면 이곳에서 바로 확인할 수 있어요. -

-
- )} + {notice.tag} + + {notice.title} + + + ))} +
-
-
-

SHORTCUT

-

바로가기

-
+
+

바로가기

-
+
{QUICK_LINKS.map(({ id, to, label, Icon }) => ( - - {label} + + {label} ))}
From 4f1652402354c0779b89320771bdc403e63e25be Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 13:08:24 +0900 Subject: [PATCH 3/4] =?UTF-8?q?:bug:=20Fix:=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B1=B4=EA=B0=95=20=EB=A0=88=ED=8F=AC=ED=8A=B8=20summary=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EB=85=B8=EC=B6=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/main/ui/sections/HealthReportSection.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/main/ui/sections/HealthReportSection.tsx b/src/pages/main/ui/sections/HealthReportSection.tsx index 73d96d4..14e23dd 100644 --- a/src/pages/main/ui/sections/HealthReportSection.tsx +++ b/src/pages/main/ui/sections/HealthReportSection.tsx @@ -159,9 +159,7 @@ export function HealthReportSection({ ? extractHealthReportRecommendations(selectedReport.healthReportContent) : []; const reportPrimaryContent = - reportRecommendations[0] || - selectedReport?.healthReportSummary || - (selectedReport ? summarizeContent(selectedReport.healthReportContent, 96) : ''); + reportRecommendations[0] || (selectedReport ? summarizeContent(selectedReport.healthReportContent, 96) : ''); return (
From 697852f413e5c202025e0a576d3589cbcbeb15b2 Mon Sep 17 00:00:00 2001 From: sooloin Date: Mon, 15 Jun 2026 13:24:24 +0900 Subject: [PATCH 4/4] =?UTF-8?q?:bug:=20Fix:=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B1=B4=EA=B0=95=20=EB=A0=88=ED=8F=AC=ED=8A=B8=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EB=B0=8F=20=EB=B3=B8=EB=AC=B8=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배경 refetch 시 스켈레톤 깜빡임이 발생하지 않도록 isLoading만 사용 - 최신 건강 레포트 탐색 로직을 단일 순회 방식으로 최적화 - healthReportContent JSON 파싱 시 null 및 타입 안전성 체크 추가 - recommendation 부재 시 raw JSON이 노출되지 않도록 안전한 본문 fallback 처리 `#60` --- src/pages/main/model/formatters.ts | 50 ++++++++++++++++--- src/pages/main/ui/LoggedInHome.tsx | 4 +- .../main/ui/sections/HealthReportSection.tsx | 9 +++- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/pages/main/model/formatters.ts b/src/pages/main/model/formatters.ts index 2bf06d1..4804174 100644 --- a/src/pages/main/model/formatters.ts +++ b/src/pages/main/model/formatters.ts @@ -32,10 +32,26 @@ export function formatDateLabel(value: string) { }).format(date); } -export function getLatestReportByPet(petId: number, reports: T[]) { - return [...reports] - .filter((report) => report.petId === petId) - .sort((left, right) => new Date(right.checkupDate).getTime() - new Date(left.checkupDate).getTime())[0]; +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) { @@ -50,14 +66,18 @@ export function summarizeContent(value: string, maxLength = 140) { 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; - if (!Array.isArray(parsed.recommendations)) { + const parsed = JSON.parse(content) as ParsedHealthReportContent | null; + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.recommendations)) { return []; } @@ -66,3 +86,21 @@ export function extractHealthReportRecommendations(content: string): string[] { 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/ui/LoggedInHome.tsx b/src/pages/main/ui/LoggedInHome.tsx index bd306fb..44f997e 100644 --- a/src/pages/main/ui/LoggedInHome.tsx +++ b/src/pages/main/ui/LoggedInHome.tsx @@ -9,7 +9,7 @@ import { HotTopicSection } from './sections/HotTopicSection'; import { NoticeAndQuickLinksSection } from './sections/NoticeAndQuickLinksSection'; export function LoggedInHome() { - const { data, isLoading, isError, error, refetch, isFetching } = useHomeDashboard(); + const { data, isLoading, isError, error, refetch } = useHomeDashboard(); const [selectedPetId, setSelectedPetId] = useState(null); const petProfiles = data?.petProfiles ?? []; @@ -28,7 +28,7 @@ export function LoggedInHome() { return (