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,