diff --git a/frontend/src/app/api/profile.ts b/frontend/src/app/api/profile.ts new file mode 100644 index 0000000..6eca42d --- /dev/null +++ b/frontend/src/app/api/profile.ts @@ -0,0 +1,147 @@ +const API_BASE = import.meta.env.VITE_API_BASE_URL; +const TOKEN_KEY = 'hyfive_token'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type PetType = 'DOG' | 'CAT'; +export type DogSize = 'SMALL' | 'MEDIUM' | 'LARGE'; +export type PetGender = 'MALE' | 'FEMALE'; + +export interface PetProfileResponse { + petId: number; + name: string; + type: PetType; + dogSize: DogSize | null; + breed: string | null; + birthdate: string | null; + ageYears: number | null; + weightKg: number | null; + gender: PetGender; + isNeutered: boolean; + profileImageUrl: string | null; + isActive: boolean; +} + +export interface UpdatePetBody { + profileImageUrl?: string; + isNeutered?: boolean; + weightKg?: number; +} + +// ── Error classes ────────────────────────────────────────────────────────────── + +export class PetApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + ) { + super(message); + this.name = 'PetApiError'; + } +} + +export class PetNetworkError extends Error { + constructor( + public cause: unknown, + public url: string, + ) { + super('Failed to reach the API server'); + this.name = 'PetNetworkError'; + } +} + +// ── Internals ────────────────────────────────────────────────────────────────── + +// 에러 응답 envelope (GlobalExceptionHandler가 반환하는 형태) +interface ApiErrorEnvelope { + data: null; + error: { code: string | number; message: string } | null; + meta: unknown; +} + +function authHeaders(): HeadersInit { + const token = localStorage.getItem(TOKEN_KEY); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function safeFetch(url: string, init?: RequestInit): Promise { + try { + return await fetch(url, init); + } catch (e) { + throw new PetNetworkError(e, url); + } +} + +// PetController는 성공 시 데이터를 직접 반환(envelope 미사용), +// 실패 시 GlobalExceptionHandler가 { data, error, meta } envelope로 반환 +async function parse(res: Response): Promise { + let body: unknown; + try { + body = await res.json(); + } catch { + throw new PetApiError(res.status, 'BAD_RESPONSE', `HTTP ${res.status} (non-JSON body)`); + } + if (!res.ok) { + const env = body as ApiErrorEnvelope | null; + throw new PetApiError( + res.status, + String(env?.error?.code ?? 'UNKNOWN'), + env?.error?.message ?? `HTTP ${res.status}`, + ); + } + return body as T; +} + +// ── Display helpers ──────────────────────────────────────────────────────────── + +export function petEmoji(type: PetType): string { + return type === 'DOG' ? '🐶' : '🐱'; +} + +export function petTag(type: PetType, dogSize: DogSize | null): string { + if (type === 'CAT') return '고양이'; + const map: Record = { SMALL: '소형견', MEDIUM: '중형견', LARGE: '대형견' }; + return dogSize ? map[dogSize] : '강아지'; +} + +export function petGenderText(gender: PetGender, isNeutered: boolean): string { + return `${gender === 'MALE' ? '수컷' : '암컷'} · ${isNeutered ? '중성화 완료' : '중성화 미실시'}`; +} + +// ── Public API functions ─────────────────────────────────────────────────────── + +/** GET /api/v1/pets/active — 현재 활성 반려동물 프로필 조회 */ +export async function getActivePet(): Promise { + const res = await safeFetch(`${API_BASE}/api/v1/pets/active`, { + headers: authHeaders(), + }); + return parse(res); +} + +/** GET /api/v1/pets — 전체 프로필 목록 조회 */ +export async function listPets(): Promise { + const res = await safeFetch(`${API_BASE}/api/v1/pets`, { + headers: authHeaders(), + }); + return parse(res); +} + +/** PATCH /api/v1/pets/{petId} — 반려동물 정보 수정 (프로필 사진·중성화·체중) */ +export async function updatePet(petId: number, body: UpdatePetBody): Promise { + const res = await safeFetch(`${API_BASE}/api/v1/pets/${petId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(body), + }); + return parse(res); +} + +/** PATCH /api/v1/pets/{petId}/activate — 활성 프로필 전환 */ +export async function activatePet(petId: number): Promise { + const res = await safeFetch(`${API_BASE}/api/v1/pets/${petId}/activate`, { + method: 'PATCH', + headers: authHeaders(), + }); + return parse(res); +} \ No newline at end of file diff --git a/frontend/src/app/components/HomeScreen.tsx b/frontend/src/app/components/HomeScreen.tsx index b502848..02630af 100644 --- a/frontend/src/app/components/HomeScreen.tsx +++ b/frontend/src/app/components/HomeScreen.tsx @@ -1,7 +1,9 @@ +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import MobileFrame from './MobileFrame'; import BottomNav from './BottomNav'; import { Bell, Camera, PenLine, ChevronRight, AlertCircle } from 'lucide-react'; +import { type PetProfileResponse, getActivePet, petEmoji, petTag } from '../api/profile'; const recentActivity = [ { id: 1, date: '3/24', type: 'medical', icon: '🏥', title: '진료 기록', desc: '하나동물병원 · 피부과 진료', tag: 'AI 자동 분류', tagColor: '#1B4B8C', tagBg: '#E8F0FA' }, @@ -17,6 +19,16 @@ const alerts = [ export default function HomeScreen() { const navigate = useNavigate(); + const [pet, setPet] = useState(null); + const [petLoading, setPetLoading] = useState(true); + const [petError, setPetError] = useState(false); + + useEffect(() => { + getActivePet() + .then(setPet) + .catch(() => setPetError(true)) + .finally(() => setPetLoading(false)); + }, []); return ( @@ -46,23 +58,49 @@ export default function HomeScreen() { style={{ background: 'linear-gradient(135deg, #1B4B8C 0%, #2E6DB4 100%)', boxShadow: '0 4px 16px rgba(27,75,140,0.25)' }} onClick={() => navigate('/profile')} > -
- 🐶 -
-
-
-

코코

- 대형견 + {petLoading ? ( +
+
+

불러오는 중...

-

골든 리트리버 · 만 4세 · 28.5kg

-

수컷 · 중성화 완료

-
- + ) : petError ? ( +
+

프로필을 불러오지 못했습니다

+

탭하여 프로필로 이동

+
+ ) : ( + <> +
+ {pet?.profileImageUrl ? ( + {pet.name} + ) : ( + {pet ? petEmoji(pet.type) : '🐾'} + )} +
+
+
+

{pet?.name ?? '—'}

+ {pet && ( + + {petTag(pet.type, pet.dogSize)} + + )} +
+

+ {[pet?.breed, pet?.ageYears != null ? `만 ${pet.ageYears}세` : null, pet?.weightKg != null ? `${pet.weightKg}kg` : null].filter(Boolean).join(' · ')} +

+

+ {pet ? `${pet.gender === 'MALE' ? '수컷' : '암컷'} · ${pet.isNeutered ? '중성화 완료' : '중성화 미실시'}` : ''} +

+
+ + + )}
diff --git a/frontend/src/app/components/PetEditScreen.tsx b/frontend/src/app/components/PetEditScreen.tsx index 66002fa..a3bbe20 100644 --- a/frontend/src/app/components/PetEditScreen.tsx +++ b/frontend/src/app/components/PetEditScreen.tsx @@ -1,18 +1,44 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import MobileFrame from './MobileFrame'; import NavHeader from './NavHeader'; -import { Camera, Check, ChevronDown, ChevronUp, Image, X } from 'lucide-react'; +import { Camera, Check, Image, X } from 'lucide-react'; +import { type PetProfileResponse, getActivePet, updatePet, petEmoji } from '../api/profile'; +import { uploadProfileImage } from '../api/onboarding'; type NeuterStatus = 'completed' | 'not_done' | 'unknown'; +function neuterStatusFromBool(isNeutered: boolean): NeuterStatus { + return isNeutered ? 'completed' : 'not_done'; +} + export default function PetEditScreen() { const navigate = useNavigate(); + const galleryInputRef = useRef(null); + const cameraInputRef = useRef(null); + + const [pet, setPet] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [neuterStatus, setNeuterStatus] = useState('completed'); - const [weight, setWeight] = useState('28.5'); + const [weight, setWeight] = useState(''); const [weightError, setWeightError] = useState(''); const [saved, setSaved] = useState(false); const [showPhotoSheet, setShowPhotoSheet] = useState(false); + const [localPhotoUrl, setLocalPhotoUrl] = useState(null); + const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null); + const [photoUploading, setPhotoUploading] = useState(false); + + useEffect(() => { + getActivePet() + .then((data) => { + setPet(data); + setNeuterStatus(neuterStatusFromBool(data.isNeutered)); + setWeight(data.weightKg != null ? String(data.weightKg) : ''); + }) + .catch(() => setError('반려동물 정보를 불러오지 못했습니다.')) + .finally(() => setLoading(false)); + }, []); const handleWeightChange = (val: string) => { setWeight(val); @@ -29,49 +55,120 @@ export default function PetEditScreen() { setWeightError(''); }; - const handleSave = () => { - if (weightError || !weight) return; + const handlePhotoFile = async (file: File) => { + setShowPhotoSheet(false); + const preview = URL.createObjectURL(file); + setLocalPhotoUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return preview; + }); + setPhotoUploading(true); + try { + const s3Url = await uploadProfileImage(file); + setPendingPhotoUrl(s3Url); + } catch { + setError('사진 업로드에 실패했습니다. 다시 시도해주세요.'); + setLocalPhotoUrl(null); + } finally { + setPhotoUploading(false); + } + }; + + const handleSave = async () => { + if (weightError || !weight || !pet) return; setSaved(true); - setTimeout(() => navigate('/home'), 900); + try { + await updatePet(pet.petId, { + isNeutered: neuterStatus === 'completed', + weightKg: parseFloat(weight), + ...(pendingPhotoUrl ? { profileImageUrl: pendingPhotoUrl } : {}), + }); + setTimeout(() => navigate('/home'), 900); + } catch { + setSaved(false); + setError('저장에 실패했습니다. 다시 시도해주세요.'); + } }; + const currentPhotoUrl = localPhotoUrl ?? pet?.profileImageUrl ?? null; + const neuterOptions: { value: NeuterStatus; label: string; desc: string; emoji: string }[] = [ { value: 'completed', label: '중성화 완료', desc: '수술을 마쳤어요', emoji: '✅' }, { value: 'not_done', label: '미실시', desc: '아직 수술 전이에요', emoji: '⭕' }, - { value: 'unknown', label: '모름', desc: '입양 전 상태 불확실', emoji: '❔' }, ]; + if (loading) { + return ( + +
+

불러오는 중...

+
+
+ ); + } + return (
- +
+ {/* Error banner */} + {error && ( +
+

{error}

+
+ )} + {/* Section: Pet Photo */}
+ {/* Hidden file inputs */} + { const f = e.target.files?.[0]; if (f) handlePhotoFile(f); }} + /> + { const f = e.target.files?.[0]; if (f) handlePhotoFile(f); }} + /> + {/* Avatar */}
- 🐶 + {currentPhotoUrl ? ( + {pet?.name + ) : ( + {pet ? petEmoji(pet.type) : '🐾'} + )}
-

코코

-

탭하여 사진 변경

+

{pet?.name ?? ''}

+

+ {photoUploading ? '업로드 중...' : '탭하여 사진 변경'} +

@@ -124,71 +221,33 @@ export default function PetEditScreen() {
-
- {/* Stepper */} - - - {/* Input */} -
+ {/* Main stepper row */} +
+
handleWeightChange(e.target.value)} - className="w-full rounded-xl text-center outline-none transition-all" + className="w-full text-center outline-none bg-transparent" style={{ - height: '52px', - fontSize: '22px', + fontSize: '36px', fontWeight: 800, - color: '#1B4B8C', - backgroundColor: '#F8FAFF', - border: weightError ? '2px solid #EF5350' : '2px solid #C5D8EE', + color: weightError ? '#EF5350' : '#0D2B5E', fontFamily: "'Noto Sans KR', sans-serif", + border: 'none', + lineHeight: 1, }} step="0.1" min="0.1" max="200" /> - - kg - + kg
- -
- {weightError ? ( -

{weightError}

- ) : ( -

- 이전 기록: 27.8 kg (2025.02.10) · +0.7 kg -

+ {weightError && ( +

{weightError}

)} - - {/* Weight guide */} -
- 📊 -

- 골든 리트리버 성체 평균 체중: 25~35 kg — 코코는 정상 범위예요 ✅ -

-
@@ -261,7 +320,7 @@ export default function PetEditScreen() {
{/* Camera */} - } - /> +
{/* Hero Card */} @@ -47,58 +106,88 @@ export default function PetProfileScreen() { boxShadow: '0 6px 24px rgba(13,43,94,0.35)', }} > + {/* Error banner */} + {error && ( +
+

{error}

+
+ )} + {/* Avatar + Basic Info */}
+
- 🐶 + {currentPhotoUrl ? ( + {pet?.name + ) : ( + {pet ? petEmoji(pet.type) : '🐾'} + )}
- {/* Edit photo overlay */}
-

코코

- - 대형견 - +

+ {pet?.name ?? '—'} +

+ {pet && ( + + {petTag(pet.type, pet.dogSize)} + + )}
-

골든 리트리버

-

등록번호 410191-000XXXX

+ {pet?.breed && ( +

{pet.breed}

+ )}
{/* Info Tags Row */} -
- {[ - { icon: , text: '만 4세 (2021.03.12)' }, - { icon: , text: '28.5 kg' }, - { icon: , text: '수컷 · 중성화 완료' }, - { icon: null, text: 'HYFIVE 등록' }, - ].map((tag, i) => ( -
- {tag.icon} - {tag.text} -
- ))} -
+ {infoTags.length > 0 && ( +
+ {infoTags.map((tag, i) => ( +
+ {tag.icon} + {tag.text} +
+ ))} +
+ )} {/* Action Buttons */}
@@ -205,23 +294,11 @@ export default function PetProfileScreen() {
- {/* H-Score */} + {/* Health Report */}
-
-
- -

-
-
- - -
-
- {/* Progress bar */} -
+ {/* Loading */} + {loading && ( +
+

불러오는 중...

+
+ )} + + {/* Error */} + {!loading && error && ( +
+

{error}

+
+ )} + {/* Pet Cards */} - {pets.map((pet) => { + {!loading && !error && pets.map((pet) => { const isSelected = selectedId === pet.id; const wasActive = pet.isActive; return ( @@ -154,36 +186,13 @@ export default function PetSwitchScreen() { {isSelected && }
- - {/* H-Score bar */} - {isSelected && ( -
-
- 건강 점수 (H-Score) - {pet.hScore} -
-
-
-
-
- )} ); })} {/* Register New Pet */}