Skip to content

Commit 645b990

Browse files
Merge pull request #45 from 9git9git/SCRUM-33-FE-프로필-API-연동
✨ [SCRUM-33] FE 프로필 API 연동
2 parents 0c8da18 + 862e3f5 commit 645b990

10 files changed

Lines changed: 257 additions & 28 deletions

File tree

apis/character.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export type CharacterResponse = {
2+
characterName: string;
3+
imageLink: string;
4+
isCollected: boolean;
5+
collectedDate: string;
6+
};
7+
8+
export const getCharacterItems = async (userId: string): Promise<Array<CharacterResponse>> => {
9+
try {
10+
const response = await fetch(
11+
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/${userId}/characters/`,
12+
{
13+
method: 'GET',
14+
headers: {
15+
'Content-Type': 'application/json',
16+
},
17+
}
18+
);
19+
20+
if (!response.ok) {
21+
throw new Error('Failed to fetch Profile items');
22+
}
23+
24+
if (response.status === 200) {
25+
const items = await response.json();
26+
console.log('items', items, items.data);
27+
return items.data as Array<CharacterResponse>;
28+
}
29+
30+
throw new Error('Failed to fetch Profile items');
31+
} catch (error) {
32+
console.error(error);
33+
34+
throw error;
35+
}
36+
};

apis/profile.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
export type ProfileResponse = {
2+
userID: string;
3+
name: string;
4+
email: string;
5+
level: number;
6+
exp: number;
7+
characterCount: number;
8+
completedTodoCount: number;
9+
};
10+
11+
export const getProfileItems = async (userId: string): Promise<ProfileResponse> => {
12+
try {
13+
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/${userId}`, {
14+
method: 'GET',
15+
headers: {
16+
'Content-Type': 'application/json',
17+
},
18+
});
19+
20+
if (!response.ok) {
21+
throw new Error('Failed to fetch Profile items');
22+
}
23+
24+
if (response.status === 200) {
25+
const items = await response.json();
26+
console.log('items', items, items.data);
27+
return items.data;
28+
}
29+
30+
throw new Error('Failed to fetch Profile items');
31+
} catch (error) {
32+
console.error(error);
33+
34+
throw error;
35+
}
36+
};
37+
38+
export type ProfileRequest = {
39+
sex: string;
40+
age: number;
41+
job: string;
42+
};
43+
44+
export const updateUserData = async (
45+
userId: string,
46+
sex: string,
47+
age: number,
48+
job: string
49+
): Promise<ProfileRequest> => {
50+
try {
51+
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/users/${userId}`, {
52+
method: 'PUT',
53+
headers: {
54+
'Content-Type': 'application/json',
55+
},
56+
body: JSON.stringify({ sex, age, job }),
57+
});
58+
59+
if (!response.ok) {
60+
throw new Error('Failed to send Profile items');
61+
}
62+
63+
if (response.status === 200) {
64+
const items = await response.json();
65+
return items.data;
66+
}
67+
68+
throw new Error('Failed to send Profile items');
69+
} catch (error) {
70+
console.error(error);
71+
72+
throw error;
73+
}
74+
};

components/service/profile/CharacterCard.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,21 @@ function CharacterCard({ imageLink, isLocked, name, className }: CharacterCardPr
3131
<div className="flex flex-col items-center justify-center">
3232
{/* 이미지 */}
3333
<Image
34-
src={`/${imageLink}`}
34+
src={imageLink}
3535
alt={altText}
3636
width={Profile_CharacterCardImage_WIDTH}
3737
height={Profile_CharacterCardImage_HEIGHT}
38-
className="object-contain"
38+
className={cn('object-contain', isLocked && 'grayscale opacity-10')}
3939
/>
4040
{/* 캐릭터 이름 */}
41-
<span className="text-black text-base font-normal tracking-tight">{displayName}</span>
41+
<span
42+
className={cn(
43+
'text-black text-base font-normal tracking-tight',
44+
isLocked && 'text-black'
45+
)}
46+
>
47+
{displayName}
48+
</span>
4249
</div>
4350
</div>
4451
);

components/service/profile/CharacterGrid.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { CharacterCard } from './CharacterCard';
22

33
type Props = {
4-
characters: Array<{ imageLink: string; isLocked: boolean; name: string }>;
4+
characters: Array<{
5+
name: string;
6+
imageLink: string;
7+
isLocked: boolean;
8+
}>;
59
};
610

711
export const CharacterGrid = ({ characters }: Props) => {

components/service/profile/CharacterGridHeader.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import { BookOpen } from 'lucide-react';
44
import { Separator } from '@/components/ui/separator';
55

6-
export default function CharacterGridHeader() {
6+
type Props = {
7+
characterCount: number;
8+
};
9+
10+
export default function CharacterGridHeader({ characterCount }: Props) {
711
return (
812
<div className="bg-beige-base rounded-xl shadow-md p-4 w-105 max-w-md mx-auto mt-4">
913
<div className="flex justify-between items-center">
@@ -12,7 +16,7 @@ export default function CharacterGridHeader() {
1216
<span className="text-lg font-bold text-black ml-2">고양이 도감</span>
1317
</div>
1418
<div className="text-sm font-semibold text-primary">
15-
4 <span className="text-black mr-3">/ 30</span>
19+
{characterCount} <span className="text-black mr-3">/ 30</span>
1620
</div>
1721
</div>
1822
<Separator className="bg-beige-deco mt-2 mb-2" />
Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,66 @@
1+
'use client';
2+
13
import { Header } from '@/components/shared/Header';
24
import { ProfileHeaderContent } from './ProfileHeaderContent';
35
import { UserStats } from './UserStats';
46
import CharacterGridHeader from './CharacterGridHeader';
57
import { CharacterGrid } from './CharacterGrid';
6-
import { DUMMY_CHARACTER_LIST } from '@/constants/character';
8+
9+
import { useUserStore } from '@/stores/user';
10+
import { getProfileItems, ProfileResponse } from '@/apis/profile';
11+
import { getCharacterItems, CharacterResponse } from '@/apis/character';
12+
import { useEffect, useState } from 'react';
13+
714
export const Profile = () => {
15+
const user = useUserStore((state) => state.user);
16+
17+
const [profile, setProfile] = useState<ProfileResponse>();
18+
const [characters, setCharacters] = useState<Array<CharacterResponse>>([]);
19+
const [isLoading, setIsLoading] = useState<boolean>(false);
20+
21+
useEffect(() => {
22+
const fetchProfile = async () => {
23+
if (!user?.id) return;
24+
25+
try {
26+
setIsLoading(true);
27+
const profileResponse = await getProfileItems(user.id);
28+
setProfile(profileResponse);
29+
const characterResponse = await getCharacterItems(user.id);
30+
setCharacters(characterResponse);
31+
} catch (e) {
32+
console.error('프로필 로딩 실패 :', e);
33+
} finally {
34+
setIsLoading(false);
35+
}
36+
};
37+
38+
fetchProfile();
39+
}, [user?.id]);
40+
41+
if (isLoading) return <div>프로필 로딩 중...</div>;
42+
if (!profile || !user?.id) return null;
43+
844
return (
945
<div className="h-full bg-beige-light">
1046
<Header>
11-
<ProfileHeaderContent />
47+
<ProfileHeaderContent id={user.id} name={profile.name} email={profile.email} />
1248
</Header>
13-
<UserStats />
14-
<CharacterGridHeader />
15-
<CharacterGrid characters={DUMMY_CHARACTER_LIST} />
49+
50+
<UserStats
51+
level={profile.level}
52+
exp={profile.exp}
53+
completedTodoCount={profile.completedTodoCount}
54+
/>
55+
56+
<CharacterGridHeader characterCount={profile.characterCount} />
57+
<CharacterGrid
58+
characters={characters.map((c) => ({
59+
imageLink: c.imageLink,
60+
isLocked: !c.isCollected,
61+
name: c.characterName,
62+
}))}
63+
/>
1664
</div>
1765
);
1866
};

components/service/profile/ProfileHeaderContent.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,20 @@ import { Button } from '@/components/ui/button';
55
import { useModalStore } from '@/stores/modal';
66
import ProfileModal from '@/components/service/profile/ProfileModal/ProfileModal';
77
import { logout } from '@/actions/logout';
8-
import { useUserStore } from '@/stores/user';
9-
export const ProfileHeaderContent = () => {
8+
9+
type Props = {
10+
id: string;
11+
name: string;
12+
email: string;
13+
};
14+
15+
export const ProfileHeaderContent = ({ id, name, email }: Props) => {
1016
const { openModal } = useModalStore();
11-
const user = useUserStore((state) => state.user);
12-
console.log('user', user);
1317

1418
const clickProfile = () => {
1519
openModal({
1620
title: '나의 정보',
17-
component: <ProfileModal />,
21+
component: <ProfileModal id={id} email={email} />,
1822
});
1923
};
2024

@@ -33,7 +37,7 @@ export const ProfileHeaderContent = () => {
3337
/>
3438
<AvatarFallback className="bg-white">U</AvatarFallback>
3539
</Avatar>
36-
<p className="text-sm font-medium">홍길동</p>
40+
<p className="text-lg">{name}</p>
3741
</div>
3842
<div className="flex items-center gap-2">
3943
<Button

components/service/profile/ProfileModal/ProfileModal.tsx

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useForm, FormProvider } from 'react-hook-form';
44
import { zodResolver } from '@hookform/resolvers/zod';
55
import { profileFormSchema, ProfileFormValues } from '@/schemas/profile';
6+
import { useState } from 'react';
67

78
import { GenderRadio } from './GenderRadio';
89
import { AgeInput } from './AgeInput';
@@ -13,8 +14,19 @@ import ConsentConfirm from './ConsentConfirm';
1314
import { PERSONAL_INFORMATION_AGREEMENT } from '@/constants/PERSONAL_INFORMATION_AGREEMENT';
1415
import { Button } from '@/components/ui/button';
1516
import { PencilLine } from 'lucide-react';
17+
import { toast } from 'sonner';
18+
19+
import { updateUserData, GenderEnum, ProfileRequest } from '@/apis/profile';
20+
21+
type Props = {
22+
id: string;
23+
email: string;
24+
};
25+
26+
export default function ProfileModal({ email, id }: Props) {
27+
const [error, setError] = useState<string | null>(null);
28+
const [isSubmitting, setIsSubmitting] = useState(false);
1629

17-
export default function ProfileModal() {
1830
const form = useForm<ProfileFormValues>({
1931
resolver: zodResolver(profileFormSchema),
2032
defaultValues: {
@@ -25,19 +37,50 @@ export default function ProfileModal() {
2537
},
2638
});
2739

28-
const onSubmit = form.handleSubmit((data) => {
29-
console.log('제출 데이터:', data);
30-
alert('제출 완료!');
40+
const saveProfile = async (data: Omit<ProfileRequest, 'id'>, userId: string): Promise<void> => {
41+
try {
42+
setError(null);
43+
setIsSubmitting(true);
44+
await updateUserData(userId, data.sex, data.age, data.job);
45+
toast.success('프로필이 저장되었어요!');
46+
} catch (err) {
47+
const message = err instanceof Error ? err.message : '프로필 저장 실패';
48+
setError(message);
49+
toast.error(message);
50+
throw err;
51+
} finally {
52+
setIsSubmitting(false);
53+
}
54+
};
55+
56+
const onSubmit = form.handleSubmit(async (data) => {
57+
try {
58+
const ageValue = Number(data.age);
59+
if (isNaN(ageValue) || ageValue <= 0) {
60+
throw new Error('나이는 숫자로 입력해주세요.');
61+
}
62+
await saveProfile(
63+
{
64+
sex: data.gender,
65+
age: Number(data.age),
66+
job: data.job,
67+
},
68+
id
69+
);
70+
toast.success('프로필이 저장되었어요!');
71+
} catch (e) {
72+
console.error(e);
73+
}
3174
});
3275

33-
const isSubmitDisabled = Object.keys(form.formState.errors).length > 0;
76+
const isSubmitDisabled = isSubmitting || Object.keys(form.formState.errors).length > 0;
3477

3578
return (
3679
<FormProvider {...form}>
3780
<form onSubmit={onSubmit}>
3881
<div className="p-4 space-y-3">
3982
<h2 className="text-lg text-secondary font-semibold">아이디</h2>
40-
<p className="text-sm text-secondary mb-3">user0408</p>
83+
<p className="text-sm text-secondary mb-3">{email}</p>
4184

4285
<GenderRadio />
4386
<AgeInput />
@@ -56,7 +99,7 @@ export default function ProfileModal() {
5699
className="bg-transparent hover:text-primary transition-colors duration-200 cursor-pointer shadow-none border-none px-0 py-0 h-auto disabled:cursor-not-allowed"
57100
>
58101
<span className="flex items-center gap-1">
59-
<PencilLine size={16} /> 완료
102+
<PencilLine size={16} /> {isSubmitting ? '저장 중...' : '완료'}
60103
</span>
61104
</Button>
62105
</div>

0 commit comments

Comments
 (0)