Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions frontend/src/app/api/profile.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
try {
return await fetch(url, init);
} catch (e) {
throw new PetNetworkError(e, url);
}
}

// PetController는 성공 시 데이터를 직접 반환(envelope 미사용),
// 실패 시 GlobalExceptionHandler가 { data, error, meta } envelope로 반환
async function parse<T>(res: Response): Promise<T> {
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<DogSize, string> = { 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<PetProfileResponse> {
const res = await safeFetch(`${API_BASE}/api/v1/pets/active`, {
headers: authHeaders(),
});
return parse<PetProfileResponse>(res);
}

/** GET /api/v1/pets — 전체 프로필 목록 조회 */
export async function listPets(): Promise<PetProfileResponse[]> {
const res = await safeFetch(`${API_BASE}/api/v1/pets`, {
headers: authHeaders(),
});
return parse<PetProfileResponse[]>(res);
}

/** PATCH /api/v1/pets/{petId} — 반려동물 정보 수정 (프로필 사진·중성화·체중) */
export async function updatePet(petId: number, body: UpdatePetBody): Promise<PetProfileResponse> {
const res = await safeFetch(`${API_BASE}/api/v1/pets/${petId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body),
});
return parse<PetProfileResponse>(res);
}

/** PATCH /api/v1/pets/{petId}/activate — 활성 프로필 전환 */
export async function activatePet(petId: number): Promise<PetProfileResponse> {
const res = await safeFetch(`${API_BASE}/api/v1/pets/${petId}/activate`, {
method: 'PATCH',
headers: authHeaders(),
});
return parse<PetProfileResponse>(res);
}
70 changes: 54 additions & 16 deletions frontend/src/app/components/HomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -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' },
Expand All @@ -17,6 +19,16 @@ const alerts = [

export default function HomeScreen() {
const navigate = useNavigate();
const [pet, setPet] = useState<PetProfileResponse | null>(null);
const [petLoading, setPetLoading] = useState(true);
const [petError, setPetError] = useState(false);

useEffect(() => {
getActivePet()
.then(setPet)
.catch(() => setPetError(true))
.finally(() => setPetLoading(false));
}, []);

return (
<MobileFrame>
Expand Down Expand Up @@ -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')}
>
<div className="w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0" style={{ backgroundColor: 'rgba(255,255,255,0.2)', border: '2px solid rgba(255,255,255,0.4)' }}>
<span style={{ fontSize: '30px' }}>🐶</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p style={{ fontSize: '16px', fontWeight: 700, color: 'white' }}>코코</p>
<span className="px-2 py-0.5 rounded-full" style={{ backgroundColor: 'rgba(255,255,255,0.2)', fontSize: '10px', color: 'rgba(255,255,255,0.9)', fontWeight: 600 }}>대형견</span>
{petLoading ? (
<div className="flex-1 flex items-center gap-3">
<div className="w-14 h-14 rounded-full flex-shrink-0" style={{ backgroundColor: 'rgba(255,255,255,0.15)' }} />
<p style={{ fontSize: '13px', color: 'rgba(255,255,255,0.6)' }}>불러오는 중...</p>
</div>
<p style={{ fontSize: '11px', color: 'rgba(255,255,255,0.8)', marginTop: '2px' }}>골든 리트리버 · 만 4세 · 28.5kg</p>
<p style={{ fontSize: '10px', color: 'rgba(255,255,255,0.65)', marginTop: '1px' }}>수컷 · 중성화 완료</p>
</div>
<button
onClick={(e) => { e.stopPropagation(); navigate('/profile'); }}
style={{ color: 'rgba(255,255,255,0.7)', background: 'none', border: 'none', cursor: 'pointer' }}
>
<ChevronRight size={18} />
</button>
) : petError ? (
<div className="flex-1">
<p style={{ fontSize: '13px', color: 'rgba(255,255,255,0.7)' }}>프로필을 불러오지 못했습니다</p>
<p style={{ fontSize: '11px', color: 'rgba(255,255,255,0.5)', marginTop: '2px' }}>탭하여 프로필로 이동</p>
</div>
) : (
<>
<div className="w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0 overflow-hidden" style={{ backgroundColor: 'rgba(255,255,255,0.2)', border: '2px solid rgba(255,255,255,0.4)' }}>
{pet?.profileImageUrl ? (
<img src={pet.profileImageUrl} alt={pet.name} className="w-full h-full object-cover" />
) : (
<span style={{ fontSize: '30px' }}>{pet ? petEmoji(pet.type) : '🐾'}</span>
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p style={{ fontSize: '16px', fontWeight: 700, color: 'white' }}>{pet?.name ?? '—'}</p>
{pet && (
<span className="px-2 py-0.5 rounded-full" style={{ backgroundColor: 'rgba(255,255,255,0.2)', fontSize: '10px', color: 'rgba(255,255,255,0.9)', fontWeight: 600 }}>
{petTag(pet.type, pet.dogSize)}
</span>
)}
</div>
<p style={{ fontSize: '11px', color: 'rgba(255,255,255,0.8)', marginTop: '2px' }}>
{[pet?.breed, pet?.ageYears != null ? `만 ${pet.ageYears}세` : null, pet?.weightKg != null ? `${pet.weightKg}kg` : null].filter(Boolean).join(' · ')}
</p>
<p style={{ fontSize: '10px', color: 'rgba(255,255,255,0.65)', marginTop: '1px' }}>
{pet ? `${pet.gender === 'MALE' ? '수컷' : '암컷'} · ${pet.isNeutered ? '중성화 완료' : '중성화 미실시'}` : ''}
</p>
</div>
<button
onClick={(e) => { e.stopPropagation(); navigate('/profile'); }}
style={{ color: 'rgba(255,255,255,0.7)', background: 'none', border: 'none', cursor: 'pointer' }}
>
<ChevronRight size={18} />
</button>
</>
)}
</div>
</div>

Expand Down
Loading
Loading